diff --git a/.gitignore b/.gitignore index 7010820..4147e43 100644 --- a/.gitignore +++ b/.gitignore @@ -95,4 +95,31 @@ nul *.sample_embeddings.json # AbletonMCP_AI generated audio -AppData/ \ No newline at end of file +AppData/ + +# Local backups and archives +AbletonMCP_AI_BAK_*/ +_archive/ + +# Ableton bundled controller content kept only on disk +Axiom_25_Classic/ +Axiom_49_61_Classic/ +BCF2000/ +BCR2000/ +KONTROL49/ +MPD32/ +MPK25/ +MPK49/ +MPK61/ +MPK88/ +Push/ +Push2/ +Roland_A_PRO/ +microKONTROL/ + +# AbletonMCP_AI runtime state +AbletonMCP_AI/diversity_memory.json +AbletonMCP_AI/MCP_Server/scan_log.txt +AbletonMCP_AI/MCP_Server/*.log +AbletonMCP_AI/MCP_Server/health_check_result.json +*.bak diff --git a/AbletonMCP_AI/MCP_Server/scan_log.txt b/AbletonMCP_AI/MCP_Server/scan_log.txt deleted file mode 100644 index b5a9314..0000000 Binary files a/AbletonMCP_AI/MCP_Server/scan_log.txt and /dev/null differ diff --git a/AbletonMCP_AI/diversity_memory.json b/AbletonMCP_AI/diversity_memory.json deleted file mode 100644 index 5ffe5b1..0000000 --- a/AbletonMCP_AI/diversity_memory.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "used_families": { - "acoustic": 8 - }, - "used_paths": { - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Dubdogz & Jude & Frank - ININNA TORA (Extended Version) [@danielcarmona_dj].mp3": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Clap 5.wav": 2, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 9.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 3.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Kick Hit 03.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Clap & Snare Hit 12.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Hat Hit 07.wav": 2, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 5.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Kick Hit 10.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Clap & Snare Hit 14.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Kit_01_OHH_A#_125.wav": 3, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Kick Hit 02.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Clap 8.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Closed Hat 6.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Kick 4.wav": 1, - "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 10.wav": 1 - }, - "generation_count": 7, - "last_updated": "2026-03-29T01:28:32.412286", - "version": "1.0" -} \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/.gitignore b/AbletonMCP_AI_BAK_20260328_200801/.gitignore deleted file mode 100644 index adc2402..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/.gitignore +++ /dev/null @@ -1,46 +0,0 @@ -__pycache__/ -*.py[cod] -*.pyo -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ -.venv/ -venv/ -.idea/ -.vscode/ -*.log -*.tmp -*.bak -*.asd -*.als -*.wav -*.aif -*.aiff -*.flac -*.ogg -*.mp3 -exports/ -render/ -renders/ -stems/ -temp/ -tmp/ -/automation/telegram.local.json -/automation/wsl_runtime/ -/automation/wsl.local.env -/automation/wsl/.env -/automation/runs/ -/automation/.task_queue.tmp* - -# Temp and debug files -*_errors*.txt -*.patch -fix.py -update_opencode.py -grant_permissions.py -GLM_TASK_*_REPORT.md -glmwork.md - -# Library paths (user-specific) -librerias/ -sample/ diff --git a/AbletonMCP_AI_BAK_20260328_200801/CLAUDE.md b/AbletonMCP_AI_BAK_20260328_200801/CLAUDE.md deleted file mode 100644 index 02fc672..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/CLAUDE.md +++ /dev/null @@ -1,727 +0,0 @@ -# AbletonMCP-AI Full Handoff - -This file is the broadest handoff in the repo. - -If another AI needs to retake the project with minimal context loss, this is the file to read first. - -Project root: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI` - -Current local branch: -- `main` - -Last pushed commit at the moment this file was updated: -- `2a0d2f3dbf5f89b18690fee2a2659957f81b8191` - -## Read Order - -Read in this order: - -1. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\CLAUDE.md` -2. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\README.md` -3. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\AI_HANDOFF.md` -4. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\ARCHITECTURE.md` -5. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\MCP_TOOLS.md` -6. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\REMOTE_PROTOCOL.md` -7. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\PROJECT_CONTEXT.md` -8. `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\GPU_DIRECTML.md` - -Useful secondary docs: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\ABLETUNES_TEMPLATE_NOTES.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\SAMPLE_SYSTEM_README.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\ROADMAP_MASTER_GLM.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\README.md` - -## What This Project Is - -AbletonMCP-AI is a hybrid system to control Ableton Live 12 from MCP, generate long editable arrangements, analyze reference tracks, retrieve similar material from a local sample library, and build original projects that feel closer to a real producer workflow than to a loop toy. - -The desired output is: - -- always Arrangement View -- editable tracks and clips -- many specialized roles -- buses and returns -- original output, not stems from the reference track - -## Core Design Rules That Must Not Be Lost - -- Arrangement-first is mandatory. -- Reference audio is for analysis, not plagiarism. -- Do not use stems from the target song in the final output. -- The system must keep working even if the M4L path is incomplete. -- Stable fallback is better than a flashy broken feature. -- The local sample library is the primary sound source. -- Validation and diagnosis matter because the stack is large and brittle. - -## Important Paths - -Main runtime: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\__init__.py` - -MCP server: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\server.py` - -Music generator: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\song_generator.py` - -Reference analysis: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\reference_listener.py` - -Sample selection: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\sample_selector.py` - -Audio resampling: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\audio_resampler.py` - -Socket smoke test: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\socket_smoke_test.py` - -Segment RAG builder: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\segment_rag_builder.py` - -Local library: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks` - -Reference folder: -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\sample` - -Ableton log: -- `C:\Users\ren\AppData\Roaming\Ableton\Live 12.0.15\Preferences\Log.txt` - -Recovery popup file: -- `C:\Users\ren\AppData\Roaming\Ableton\Live 12.0.15\Preferences\CrashRecoveryInfo.cfg` - -User library: -- `C:\Users\ren\Documents\Ableton\User Library` - -## External Assets And References Used During Development - -Reference tracks that shaped the direction: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\sample\Eli Brown x GeezLy - Me Gusta.mp3` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\sample\Mr. Pauer, Goyo - Quimica (Video Oficial).mp3` - -Producer template reference pack: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\Abletunes_Free_Templates_Pack` - -The reference tracks were used for: - -- BPM and key estimation -- section and energy direction -- sample retrieval guidance -- stylistic remake goals - -They were not supposed to be used as final stems. - -## The Story From The Beginning Until Now - -### Phase 0: Initial Goal - -The original goal was not only to make sounds in Ableton, but to give the system the ability to: - -- receive a prompt or a reference MP3 -- understand the style and structure -- choose similar sounds from the local library -- generate a long arrangement that feels professionally produced -- keep the result editable inside Ableton - -Very early it became clear that a plain Session View loop machine was not enough. - -### Phase 1: Make The Remote Script Actually Work - -The first major work was stabilizing the remote layer between MCP and Ableton. - -Main problems solved in that phase: - -- command naming mismatches between MCP and the Remote Script -- parameter normalization like `track_index`, `clip_index`, `scene_index` -- note writing API mismatch in Live -- socket protocol mismatch -- stale or broken sample manager initialization - -This phase made the project usable enough to: - -- create tracks -- create clips -- write MIDI -- query session state -- build the first generated projects - -### Phase 2: Arrangement-First Pivot - -At that point the output still behaved too much like Session clips and loops. The user explicitly wanted to see everything in Arrangement View. - -That created the second major architectural pivot: - -1. generate blueprint in Session -2. commit Session to Arrangement in ordered scene playback -3. place audio fallback and overlays in Arrangement - -This became the stable route. - -Important lesson: - -- precreating certain audio tracks before the Session to Arrangement commit produced silent or broken sets -- the stable route is still: Session blueprint first, Arrangement commit second, audio layers after that - -### Phase 3: Richer Project Shape - -Once Arrangement-first worked, the next problem was musical complexity. The output felt like repeated loops. - -The generator was expanded with: - -- more track roles -- section-aware pattern generation -- richer drums, bass, melodic and FX layers -- scene naming and locators -- guide tracks -- more realistic arrangement structures - -Important roles that became standard: - -- kick -- clap -- snare fill -- hat closed -- hat open -- top loop -- percussion -- ride -- tom fill -- sub bass -- bass -- drone -- chords -- pluck -- vocal chop -- stab -- pad -- arp -- lead -- counter -- crash -- reverse FX -- riser FX -- impact FX -- atmos -- plus many `AUDIO ...` tracks - -### Phase 4: Local Library As Primary Source - -The next major issue was weak selection quality. At one point the code was pointed to a small mirror path and not to the real large library. - -The real usable library was: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks` - -That directory had hundreds of real audio assets, while the old fallback mirror only had a few dozen. - -Fixing that changed the quality of: - -- drum matching -- bass loop matching -- vocal loop matching -- FX and atmos selection - -This was a major turning point because the generated output stopped sounding starved. - -### Phase 5: Reference-Led Reconstruction - -After the library path was fixed, the project started using references more seriously. - -The target workflow became: - -- analyze the reference -- infer BPM, key, energy, style and section behavior -- find similar sounds in the local library -- reconstruct a new original track - -There was a temporary detour where stems of a reference song were materialized for analysis, but that was explicitly rejected because the desired product is not a stem-based copy workflow. - -The rule became strict: - -- no final stems from the reference -- only original reconstruction using local assets and generated MIDI - -### Phase 6: Buses, Returns, Master, And Production Logic - -The project then moved from "long loop" to "production-shaped session". - -Major additions: - -- real bus tracks -- return tracks -- track routing into buses -- role-based mixing -- return snapshots by section -- master chain blueprint - -Typical bus layout now: - -- drums -- bass -- music -- vocal -- FX - -Typical return layout now: - -- space -- echo -- heat -- glue - -This phase made the result feel more like a produced project and less like independent loops. - -### Phase 7: Template Analysis - -The project analyzed professional Abletunes templates to absorb real producer patterns. - -Main conclusions: - -- professional templates are Arrangement-heavy -- there are many specialized layers, not just one drum loop and one bass loop -- sidechain triggers, transitions, buses, returns and printed audio are common -- arrangement blocks are often 16, 32 or 64 bars -- heavy automation is normal - -This analysis informed later changes in: - -- section shape -- layer count -- transition behavior -- bus logic - -### Phase 8: Audio Fallback And Hybrid Output - -The system learned to combine: - -- MIDI and stock instruments -- local audio loops and hits -- special `AUDIO ...` overlays - -This was critical because: - -- sometimes stock-device generation gives editability and musical logic -- sometimes local audio assets give the genre-specific realism that MIDI alone cannot deliver - -So the stack became hybrid by design, not by accident. - -### Phase 9: Audio Resampling And Derived FX - -Then came the derived transition layer. - -The system added support for: - -- reverse FX -- riser -- downlifter -- stutter - -This area improved through: - -- `audio_resampler.py` -- reference-driven placement -- later bugfixes for short clips and defensive rendering - -One real bug that had to be solved: - -- `AUDIO RESAMPLE STUTTER` failed until the resampler and short-clip FFT handling were hardened - -That was fixed later and validated in real Live runs. - -### Phase 10: GPU And Deeper Reference Listening - -The user specifically asked to use GPU if possible. - -The system moved toward: - -- `DirectML` -- more expensive analysis of the reference -- segmented analysis at multiple window sizes -- heavier similarity scoring - -This became one of the most important changes for retrieval quality. - -The reference listener started doing: - -- segmenting the reference into short windows -- scoring by role -- deeper reranking -- using local metadata and cache - -This was the start of an audio-retrieval-style workflow rather than simple filename matching. - -### Phase 11: Segment RAG Direction - -To go further, the project started building a segment-level retrieval cache for the local library. - -That work added: - -- persistent per-segment cache files -- a segment builder CLI -- partial index expansion over the real library - -This is not a text RAG in the usual sense. It is closer to: - -- segmented audio retrieval -- coarse search plus rerank -- role-aware filtering -- diversity constraints - -This remains one of the most promising long-term directions for better remake quality. - -### Phase 12: GLM Workflow - -At some point token efficiency became a concern, so GLM-5 was introduced as a worker model. - -The workflow that proved useful was: - -1. Codex writes a narrow `.md` task -2. GLM edits only 1 to 3 related files -3. Codex reviews the diff -4. Codex corrects technical mistakes and validates the runtime - -What GLM was good at: - -- narrow feature implementation -- heuristic expansion -- helper tools -- reports and manifests - -What GLM was bad at: - -- declaring things complete too early -- runtime-sensitive work without supervision -- architectural judgment -- avoiding diff inflation - -Practical verdict: - -- useful as a worker -- not reliable enough as the sole closer of large features - -### Phase 13: Temporary WSL / n8n / Local Gitea Automation Detour - -There was a detour into WSL orchestration, n8n, local Gitea and Telegram loops. - -Scaffolding was generated for: - -- WSL deployment -- Docker Compose -- n8n flows -- Telegram notifications -- GLM to Codex automation loops - -That stack produced a lot of files under: - -- `automation\` -- `automation\wsl\` - -But the key lesson was: - -- the deployment summary was overstated -- the generated stack was not a truly finished deployment -- the main product value still lives in the music system, not in orchestration - -Because of that, the project intentionally pivoted back to the flow that worked: - -- Codex writes focused `.md` -- GLM does bounded work -- Codex reviews and fixes - -### Phase 14: Retrieval Hardening, Pattern Banks, Transition Materialization - -After the orchestration detour, the roadmap was re-centered on the actual product. - -Task batches improved: - -- role-safe retrieval -- repetition penalties -- more section pattern banks -- transition event materialization - -This reduced some of the "same loop again" feeling, but did not solve everything. - -### Phase 15: Device Automation Snapshots - -The latest work pushed into: - -- track device parameter snapshots -- return device parameter snapshots -- master section snapshots -- automation summaries - -GLM implemented part of that, but the raw result was not correct. - -The real fixes applied afterward were: - -- use the already existing `SECTION_DEVICE_AUTOMATION` instead of duplicate tables -- use the already existing `MASTER_DEVICE_AUTOMATION` -- flatten `device_parameters` into the format the server actually applies -- make the server accept both flat and nested payloads defensively -- add `mix_automation_summary` -- add `mix_automation_warnings` - -This work lives mainly in: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\song_generator.py` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\server.py` - -### Phase 16: Fix for "Only Piano" Issue (Audio Samples Not Loading) - -The user reported: "lo que me generaste solo tiene algunos pianos, nada de sonidos de mi biblioteca!" - the generated tracks only had piano/MIDI sounds, no actual audio samples from the local library. - -Root causes found and fixed: - -1. **`ROLE_PATTERNS` used non-recursive globs** in `reference_listener.py` lines 1228-1254: - - Patterns like `'*Kick*.wav'` couldn't match files in subdirectories like `loops/kick/` - - Fixed by changing to recursive patterns: `'**/*Kick*.wav'` - -2. **Method resolution bug** in `reference_listener.py`: - - `_get_role_section_features` and `_section_character_bonus` are methods in `SectionDetector` class - - Were being called as `self._method` from `ReferenceAudioListener` instead of `self._section_detector._method` - - Fixed at lines 3247 and 3270-3272 - -After fixes, verification showed: -- Reference audio plan builds correctly with 30+ layers -- `_materialize_reference_audio_layers()` creates actual audio tracks -- Real samples from local library are used (e.g., "mt kick hit 10.wav", "bbh - primer impacto - bass loop 03 g#m.wav") -- 34 audio tracks created (8 derived + 26 base), 0 errors - -## What Currently Works - -The system can currently: - -- generate full projects in Arrangement View -- build MIDI plus stock-device layers -- build many `AUDIO ...` layers from the local library -- analyze a reference track -- build a retrieval-guided original result -- commit scenes to Arrangement -- create buses and returns -- route tracks into buses -- apply track, return and master snapshots -- diagnose the generated set -- validate the generated set -- use DirectML for deeper matching work - -## What Is Stable - -The most stable route today is: - -1. analyze reference if one is available -2. build config in `song_generator.py` -3. materialize Session blueprint through the runtime -4. commit Session to Arrangement -5. place audio layers in Arrangement -6. validate and diagnose - -Do not casually change this order. - -## What Is Still Weak - -The project still has real weaknesses: - -- some generations still feel too loop-based -- retrieval can still pick poor family matches -- remake quality is not yet close enough to a convincing stylistic remake -- some runs still overuse familiar sound families -- loudness and gain staging can vary too much between runs -- derived resample layers are not always present in the final set -- the MCP wrapper for `generate_track` can time out even when Live keeps working - -## Known Operational Problems - -### 1. Recovery popup can block everything - -If Live crashes or thinks recovery is needed, a modal popup can block the socket. - -The file involved is: - -- `C:\Users\ren\AppData\Roaming\Ableton\Live 12.0.15\Preferences\CrashRecoveryInfo.cfg` - -Practical fix used during development: - -- kill Ableton -- blank that file -- restart Ableton - -### 2. MCP wrapper timeout - -The MCP tool wrapper around `generate_track` often times out at about 120 seconds. - -Important: - -- timeout does not always mean generation failed -- often the set keeps building inside Live - -Safer checks after a timeout: - -- `get_session_info()` -- `get_tracks()` -- `validate_set()` -- `diagnose_generated_set()` - -### 3. Runtime state can drift from stored manifest - -At least once, `get_generation_manifest()` returned stale data from an older generation while the actual current set in Live was already different. - -Trust runtime state first: - -- session info -- track list -- diagnosis -- validation - -Manifest is useful, but not always the freshest source. - -### 4. GLM reports often exaggerate completion - -Never trust a GLM report by itself. -Always compare: - -- report -- actual diff -- runtime result - -## Current Validation Habit - -Minimum technical checks after code edits: - -```powershell -python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\audio_resampler.py" -python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\reference_listener.py" -python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\sample_selector.py" -python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\server.py" -python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\song_generator.py" -python -m py_compile "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\__init__.py" -``` - -Minimum Live checks: - -- `get_session_info()` -- `get_tracks()` -- `validate_set(check_clips=True, check_gain=True, check_routing=True)` -- `diagnose_generated_set()` - -Useful direct smoke test: - -```powershell -cd "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server" -python socket_smoke_test.py -``` - -## Current Roadmap Priority - -The active roadmap is: - -1. better library retrieval -2. more real section variation -3. better transition and derived FX placement -4. better device automation -5. gain staging and loudness consistency -6. better remake ability -7. stronger QA and export - -The roadmap source file is: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\ROADMAP_MASTER_GLM.md` - -## Current GLM Workflow - -Keep GLM on short, bounded tasks only. - -Recent task files: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_NEXT_TASK_001_RETRIEVAL_ROLE_PENALTIES.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_NEXT_TASK_002_SECTION_PATTERN_BANK_EXPANSION.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_NEXT_TASK_003_TRANSITION_EVENT_MATERIALIZATION.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_NEXT_TASK_004_DEVICE_AUTOMATION_SNAPSHOTS.md` - -Corresponding reports: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_TASK_001_REPORT.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_TASK_002_REPORT.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_TASK_003_REPORT.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\GLM_TASK_004_REPORT.md` - -Recommended pattern: - -1. Codex writes one narrow task md -2. GLM edits only 1 to 3 files -3. Codex reviews diff -4. Codex corrects technical and runtime mistakes -5. only then decide whether the task is really done - -## Current Documentation Map - -Core repo docs: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\README.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\CLAUDE.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\AI_HANDOFF.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\ARCHITECTURE.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\MCP_TOOLS.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\REMOTE_PROTOCOL.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\PROJECT_CONTEXT.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\docs\GPU_DIRECTML.md` - -Generator and retrieval docs: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\ABLETUNES_TEMPLATE_NOTES.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server\SAMPLE_SYSTEM_README.md` - -Roadmaps and backlog: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\roadmap.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\roadmap2.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\ROADMAP_MASTER_GLM.md` - -Automation detour docs: - -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\README.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\MASTER_AUTONOMOUS_ROADMAP.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\wsl\README.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\automation\wsl\DEPLOYMENT_SUMMARY.md` -- `C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\WSL_STACK.md` - -## If You Need To Resume Development Safely - -Start here: - -1. read this file -2. read the current roadmap -3. inspect local git status -4. compile changed Python files -5. restart Live if `__init__.py` changed -6. clear recovery popup if needed -7. validate with `get_session_info`, `get_tracks`, `validate_set`, `diagnose_generated_set` -8. only then touch generation logic - -## Current Honest Product Status - -This project is no longer a basic prototype. - -It is already an advanced Ableton generation system that can: - -- create long arrangements -- use references intelligently -- retrieve from a large local library -- mix MIDI and audio material -- build buses, returns and snapshots -- generate editable results in Arrangement View - -But it is still not finished. - -The main gap is no longer raw plumbing. The main gap is artistic fidelity: - -- better retrieval -- better variation -- better remake quality -- more consistent mix quality - -That is the real work that remains. diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md deleted file mode 100644 index ff7dcf3..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/ABLETUNES_TEMPLATE_NOTES.md +++ /dev/null @@ -1,39 +0,0 @@ -# Abletunes Template Notes - -Estos templates muestran patrones claros de produccion real que conviene copiar en el generador. - -## Patrones fuertes - -- Son `arrangement-first`, no `session-first`. En los cuatro sets los clips viven casi enteros en Arrangement y las scenes estan vacias o sin rol productivo. -- Todos usan locators para secciones (`Intro`, `Breakdown`, `Drop`, `Break`, `Outro`, `End`) y esas secciones casi siempre caen en bloques de `16`, `32`, `64`, `96` o `128` beats. -- Siempre hay jerarquia por grupos: drums/top drums, bass, instruments, vox, fx. -- Casi siempre existe un `SC Trigger` o pista equivalente dedicada al sidechain. -- Los drums no son una sola pista. Hay capas separadas para kick, clap, snare, hats, ride, perc, fills, crashes, risers y FX. -- Las partes armonicas tampoco son una sola pista. Aparecen capas distintas para bassline, reese/sub, chord, piano, string, pluck, lead y layers. -- Mezclan MIDI e audio de forma agresiva. Un productor no se queda solo con MIDI: imprime loops, resamples, freeze y audios procesados cuando hace falta. -- Hay bastante tratamiento por pista: `Eq8`, `Compressor2`, `Reverb`, `AutoFilter`, `PingPongDelay`, `GlueCompressor`, `MultibandDynamics`, `Limiter`, `Saturator`. - -## Lo que mas importa para el MCP - -- El generador no tiene que crear "un loop largo". Tiene que crear secciones con mutaciones claras entre una y otra. -- Cada seccion necesita variacion de densidad, no solo mute/unmute basico. Los templates meten fills, crashes, reverse FX, chants, top loops y capas extra solo en puntos de tension. -- El arreglo profesional usa mas pistas especializadas de las que hoy genera el MCP. La separacion por rol es parte del sonido. -- Hay que imprimir mas audio original derivado del propio proyecto: resamples, reverses, freezes y FX hechos a partir de material propio. -- Los returns son pocos pero concretos. No hace falta llenar de sends; hace falta `reverb`, `delay` y buses de grupo bien usados. - -## Señales concretas vistas en el pack - -- `Abletunes - Dope As F_ck`: `128 BPM`, 6 grupos, 2 returns, `Sylenth1` dominante, mucha automatizacion (`8121` eventos). -- `Abletunes - Freedom`: `126 BPM`, mezcla house mas simple, bateria muy separada, menos automatizacion, mucho `OriginalSimpler` + `Serum`. -- `Abletunes - Hideout`: set largo y cargado, `Massive` + `Sylenth1`, una bateria enorme y mucha automatizacion (`6470` eventos). -- `Abletunes - Nobody's Watching`: enfoque mas stock, usa `Operator`, `Simpler`, bastante audio vocal y FX impresos. - -## Reglas que deberiamos incorporar - -- Generar por defecto en Arrangement, con locators reales y secciones de 16/32 bars. -- Añadir `SC Trigger`, grupos y returns fijos desde el blueprint. -- Separar drums en mas roles: kick, clap main, clap layer, snare fill, hats, ride, perc main, perc FX, crash, reverse, riser. -- Separar armonia y hooks: sub, bassline, chord stab, piano/keys, string/pad, pluck, lead, accent synth. -- Crear eventos de transicion por seccion: uplifter, downlifter, reverse crash, vocal chop, tom fill. -- Imprimir audio derivado del material generado cuando una capa necesite mas impacto o textura. -- Meter automatizacion por seccion en filtros, sends, volumen de grupos y FX de transicion. diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/SAMPLE_SYSTEM_README.md b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/SAMPLE_SYSTEM_README.md deleted file mode 100644 index 9d6835c..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/SAMPLE_SYSTEM_README.md +++ /dev/null @@ -1,203 +0,0 @@ -# Sistema de Gestión de Samples - AbletonMCP-AI - -Sistema completo de indexación, clasificación y selección inteligente de samples musicales. - -## Componentes - -### 1. `audio_analyzer.py` - Análisis de Audio - -Detecta automáticamente características de archivos de audio: -- **BPM**: Detección de tempo mediante análisis de onset -- **Key**: Detección de tonalidad mediante cromagrama -- **Tipo**: Clasificación en kick, snare, bass, synth, etc. -- **Características espectrales**: Centroide, rolloff, RMS - -**Uso básico:** -```python -from audio_analyzer import analyze_sample - -result = analyze_sample("path/to/sample.wav") -print(f"BPM: {result['bpm']}, Key: {result['key']}") -print(f"Tipo: {result['sample_type']}") -``` - -**Backends:** -- `librosa`: Análisis completo (requiere instalación) -- `basic`: Análisis por nombre de archivo (sin dependencias) - -### 2. `sample_manager.py` - Gestión de Librería - -Gestor completo de la librería de samples: -- Indexación recursiva de directorios -- Clasificación automática por categorías -- Metadatos extensibles (tags, rating, géneros) -- Búsqueda avanzada con múltiples filtros -- Persistencia en JSON - -**Categorías principales:** -- `drums`: kick, snare, clap, hat, perc, shaker, tom, cymbal -- `bass`: sub, bassline, acid -- `synths`: lead, pad, pluck, chord, fx -- `vocals`: vocal, speech, chant -- `loops`: drum_loop, bass_loop, synth_loop, full_loop -- `one_shots`: hit, noise - -**Uso básico:** -```python -from sample_manager import SampleManager - -# Inicializar -manager = SampleManager(r"C:\Users\ren\embeddings\all_tracks") - -# Escanear -stats = manager.scan_directory(analyze_audio=True) - -# Buscar -kicks = manager.search(sample_type="kick", key="Am", bpm=128) -house_samples = manager.search(genres=["house"], limit=10) - -# Obtener pack completo -pack = manager.get_pack_for_genre("techno", key="F#m", bpm=130) -``` - -### 3. `sample_selector.py` - Selección Inteligente - -Selección contextual basada en género, key y BPM: -- Perfiles de género predefinidos -- Matching armónico entre samples -- Generación de kits de batería coherentes -- Mapeo MIDI automático - -**Géneros soportados:** -- Techno (industrial, minimal, acid) -- House (deep, classic, progressive) -- Tech-House -- Trance (progressive, psy) -- Drum & Bass (liquid, neuro) -- Ambient - -**Uso básico:** -```python -from sample_selector import SampleSelector - -selector = SampleSelector() - -# Seleccionar para un género -group = selector.select_for_genre("techno", key="F#m", bpm=130) - -# Acceder a elementos -group.drums.kick # Sample de kick -group.bass # Lista de bass samples -group.synths # Lista de synths - -# Mapeo MIDI -mapping = selector.get_midi_mapping_for_kit(group.drums) - -# Cambio de key armónico -new_key = selector.suggest_key_change("Am", "fifth_up") # Em -``` - -## Integración con MCP Server - -El servidor MCP expone las siguientes herramientas: - -### Gestión de Librería -- `scan_sample_library` - Escanear directorio de samples -- `get_sample_library_stats` - Estadísticas de la librería - -### Búsqueda y Selección -- `advanced_search_samples` - Búsqueda con filtros múltiples -- `select_samples_for_genre` - Selección automática por género -- `get_drum_kit_mapping` - Kit de batería con mapeo MIDI -- `get_sample_pack_for_project` - Pack completo para proyecto - -### Análisis y Compatibilidad -- `analyze_audio_file` - Analizar archivo de audio -- `find_compatible_samples` - Encontrar samples compatibles -- `suggest_key_change` - Sugerir cambios de tonalidad - -## Estructura de Datos - -### Sample -```python -@dataclass -class Sample: - id: str # ID único - name: str # Nombre del archivo - path: str # Ruta completa - category: str # Categoría principal - subcategory: str # Subcategoría - sample_type: str # Tipo específico - key: Optional[str] # Tonalidad (Am, F#m, C) - bpm: Optional[float] # BPM - duration: float # Duración en segundos - genres: List[str] # Géneros asociados - tags: List[str] # Tags - rating: int # Rating 0-5 -``` - -### DrumKit -```python -@dataclass -class DrumKit: - name: str - kick: Optional[Sample] - snare: Optional[Sample] - clap: Optional[Sample] - hat_closed: Optional[Sample] - hat_open: Optional[Sample] - perc1: Optional[Sample] - perc2: Optional[Sample] -``` - -## Mapeo MIDI - -Notas estándar para drums: -- `36` (C1): Kick -- `38` (D1): Snare -- `39` (D#1): Clap -- `42` (F#1): Closed Hat -- `46` (A#1): Open Hat -- `41` (F1): Tom Low -- `49` (C#2): Crash - -## Ejemplos de Uso - -### Crear un track completo -```python -# Seleccionar samples para techno -selector = get_selector() -group = selector.select_for_genre("techno", key="F#m", bpm=130) - -# Usar con Ableton -ableton = get_ableton_connection() - -# Crear tracks y cargar samples -for i, sample in enumerate([group.drums.kick, group.drums.snare]): - if sample: - print(f"Cargar {sample.name} en track {i}") -``` - -### Buscar samples compatibles -```python -# Encontrar samples que combinen con un kick -kick = manager.get_by_path("path/to/kick.wav") -compatible = selector.find_compatible_samples(kick, max_results=5) - -for sample, score in compatible: - print(f"{sample.name}: {score:.1%} compatible") -``` - -## Archivos Generados - -- `.sample_cache/sample_library.json` - Índice de la librería -- `.sample_cache/library_stats.json` - Estadísticas - -## Dependencias Opcionales - -Para análisis de audio completo: -```bash -pip install librosa soundfile numpy -``` - -Sin estas dependencias, el sistema funciona en modo "basic" usando metadatos de los nombres de archivo. diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/__init__.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/__init__.py deleted file mode 100644 index aef464d..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -MCP Server para AbletonMCP-AI -Servidor FastMCP que conecta Claude con Ableton Live 12 -""" - -from .server import mcp, main -from .song_generator import SongGenerator -from .sample_index import SampleIndex - -# Nuevo sistema de samples -try: - SAMPLE_SYSTEM_AVAILABLE = True -except ImportError: - SAMPLE_SYSTEM_AVAILABLE = False - -__all__ = [ - 'mcp', 'main', - 'SongGenerator', 'SampleIndex', -] - -if SAMPLE_SYSTEM_AVAILABLE: - __all__.extend([ - 'SampleManager', 'Sample', 'get_manager', - 'SampleSelector', 'get_selector', 'DrumKit', 'InstrumentGroup', - 'AudioAnalyzer', 'analyze_sample', 'SampleType', - ]) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent11_harmony_review.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent11_harmony_review.py deleted file mode 100644 index 470f44a..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent11_harmony_review.py +++ /dev/null @@ -1,318 +0,0 @@ -import json -import socket -from datetime import datetime -import os - -LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent11_review_harmony.txt" - -CHORD_TONES = { - "Am": [57, 60, 64], - "F": [53, 57, 60], - "C": [48, 52, 55], - "G": [43, 47, 50] -} - -CHORD_NAMES = { - "Am": ["A", "C", "E"], - "F": ["F", "A", "C"], - "C": ["C", "E", "G"], - "G": ["G", "B", "D"] -} - -AM_SCALE = [57, 59, 60, 62, 64, 65, 67] - -PROGRESSION_ORDER = ["Am", "F", "C", "G"] -CHORD_DURATION = 8.0 - -def pitch_to_name(pitch): - names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] - return names[pitch % 12] - -def get_chord_at_time(start_time): - chord_index = int(start_time // CHORD_DURATION) % 4 - return PROGRESSION_ORDER[chord_index] - -def normalize_to_octave(pitch, target_octave=3): - return (pitch % 12) + (target_octave * 12) - -class AbletonSocketClient: - def __init__(self, host="127.0.0.1", port=9877, timeout=15.0): - self.host = host - self.port = port - self.timeout = timeout - - def send(self, command_type, params=None): - payload = json.dumps({ - "type": command_type, - "params": params or {}, - }).encode("utf-8") + b"\n" - - with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: - sock.sendall(payload) - reader = sock.makefile("r", encoding="utf-8") - try: - line = reader.readline() - finally: - reader.close() - try: - sock.shutdown(socket.SHUT_RDWR) - except OSError: - pass - - if not line: - raise RuntimeError(f"No response for command: {command_type}") - - return json.loads(line) - -def log_message(msg): - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - log_line = f"[{timestamp}] {msg}\n" - print(log_line.strip()) - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(log_line) - -def analyze_track_harmony(client, track_index, track_name, scene_index=0): - issues = [] - notes_in_key = 0 - notes_out_of_key = 0 - chord_matches = 0 - chord_mismatches = 0 - - try: - response = client.send("get_notes", { - "track_index": track_index, - "scene_index": scene_index - }) - - if response.get("status") != "success": - return {"error": response.get("message", "Unknown error")} - - notes = response.get("result", {}).get("notes", []) - - if not notes: - return {"warning": "No notes found in clip"} - - for note in notes: - pitch = note.get("pitch", 60) - start = note.get("start", 0) - duration = note.get("duration", 1) - - pitch_class = pitch % 12 - current_chord = get_chord_at_time(start) - - in_am_scale = any((pitch % 12) == (p % 12) for p in AM_SCALE) - - if in_am_scale: - notes_in_key += 1 - else: - notes_out_of_key += 1 - issues.append({ - "type": "out_of_key", - "pitch": pitch, - "pitch_name": pitch_to_name(pitch), - "start": start, - "expected": "Am scale (A, B, C, D, E, F, G)" - }) - - chord_tones_normalized = [t % 12 for t in CHORD_TONES[current_chord]] - if pitch_class in chord_tones_normalized: - chord_matches += 1 - else: - chord_mismatches += 1 - chord_tone_names = CHORD_NAMES[current_chord] - issues.append({ - "type": "chord_tone_mismatch", - "pitch": pitch, - "pitch_name": pitch_to_name(pitch), - "start": start, - "chord": current_chord, - "expected_chord_tones": chord_tone_names - }) - - return { - "total_notes": len(notes), - "notes_in_key": notes_in_key, - "notes_out_of_key": notes_out_of_key, - "chord_matches": chord_matches, - "chord_mismatches": chord_mismatches, - "issues": issues - } - - except Exception as e: - return {"error": str(e)} - -def analyze_bass_notes(client, track_index, scene_index=0): - issues = [] - correct_roots = 0 - incorrect_roots = 0 - - try: - response = client.send("get_notes", { - "track_index": track_index, - "scene_index": scene_index - }) - - if response.get("status") != "success": - return {"error": response.get("message", "Unknown error")} - - notes = response.get("result", {}).get("notes", []) - - if not notes: - return {"warning": "No bass notes found"} - - ROOT_NOTES = { - "Am": 57, - "F": 53, - "C": 48, - "G": 43 - } - - for note in notes: - pitch = note.get("pitch", 60) - start = note.get("start", 0) - - current_chord = get_chord_at_time(start) - expected_root = ROOT_NOTES[current_chord] - expected_root_class = expected_root % 12 - pitch_class = pitch % 12 - - if pitch_class == expected_root_class: - correct_roots += 1 - else: - incorrect_roots += 1 - if start % 4.0 < 0.5: - issues.append({ - "type": "wrong_bass_root", - "pitch": pitch, - "pitch_name": pitch_to_name(pitch), - "start": start, - "chord": current_chord, - "expected_root": pitch_to_name(expected_root) - }) - - return { - "total_notes": len(notes), - "correct_roots": correct_roots, - "incorrect_roots": incorrect_roots, - "issues": issues - } - - except Exception as e: - return {"error": str(e)} - -def main(): - os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) - - log_message("=" * 60) - log_message("AGENT 11 - HARMONIC COHERENCE REVIEW") - log_message("=" * 60) - log_message(f"Target progression: Am - F - C - G (8 beats each)") - log_message(f"Am scale: A, B, C, D, E, F, G") - log_message("") - - client = AbletonSocketClient() - - session = client.send("get_session_info") - if session.get("status") != "success": - log_message("ERROR: Cannot connect to Ableton session") - return - - log_message(f"Session: {session.get('result', {}).get('num_tracks', 0)} tracks, " - f"tempo: {session.get('result', {}).get('tempo', 120)} BPM") - - tracks_response = client.send("get_tracks") - if tracks_response.get("status") != "success": - log_message("ERROR: Cannot get tracks") - return - - tracks = tracks_response.get("result", []) - - midi_tracks = [ - (i, t.get("name", "Unknown"), t.get("session_clip_count", 0)) - for i, t in enumerate(tracks) - if t.get("has_midi_input") and t.get("session_clip_count", 0) > 0 - ] - - log_message(f"Found {len(midi_tracks)} MIDI tracks with clips") - log_message("") - - total_issues = 0 - critical_issues = 0 - - for track_index, track_name, clip_count in midi_tracks: - log_message(f"\n--- TRACK {track_index}: {track_name} ---") - - if "BASS" in track_name.upper(): - log_message("Analyzing as BASS track (checking root notes)") - result = analyze_bass_notes(client, track_index) - else: - log_message("Analyzing harmonic content") - result = analyze_track_harmony(client, track_index, track_name) - - if "error" in result: - log_message(f" ERROR: {result['error']}") - continue - - if "warning" in result: - log_message(f" WARNING: {result['warning']}") - continue - - if "total_notes" in result: - log_message(f" Total notes: {result['total_notes']}") - - if "notes_in_key" in result: - log_message(f" Notes in Am scale: {result['notes_in_key']}/{result['total_notes']}") - if result["notes_out_of_key"] > 0: - log_message(f" OUT OF KEY: {result['notes_out_of_key']} notes") - total_issues += result["notes_out_of_key"] - - if "chord_matches" in result: - log_message(f" Chord tone matches: {result['chord_matches']}/{result['total_notes']}") - if result["chord_mismatches"] > 0: - log_message(f" CHORD MISMATCHES: {result['chord_mismatches']} notes") - - if "correct_roots" in result: - log_message(f" Correct bass roots: {result['correct_roots']}/{result['total_notes']}") - if result["incorrect_roots"] > 0: - log_message(f" WRONG BASS ROOTS: {result['incorrect_roots']} notes") - total_issues += result["incorrect_roots"] - critical_issues += result["incorrect_roots"] - - if result.get("issues"): - for issue in result["issues"][:5]: - if issue["type"] == "out_of_key": - log_message(f" [ISSUE] Note {issue['pitch_name']}{issue['pitch']} at beat {issue['start']:.1f} " - f"not in Am scale") - elif issue["type"] == "chord_tone_mismatch": - log_message(f" [ISSUE] Note {issue['pitch_name']}{issue['pitch']} at beat {issue['start']:.1f} " - f"not in chord {issue['chord']} (expected: {issue['expected_chord_tones']})") - elif issue["type"] == "wrong_bass_root": - log_message(f" [CRITICAL] Bass note {issue['pitch_name']}{issue['pitch']} at beat {issue['start']:.1f} " - f"should be {issue['expected_root']} for chord {issue['chord']}") - - log_message("\n" + "=" * 60) - log_message("HARMONIC COHERENCE SUMMARY") - log_message("=" * 60) - - if critical_issues > 0: - log_message(f"STATUS: CRITICAL ISSUES FOUND") - log_message(f" - {critical_issues} critical bass root mismatches") - log_message(f" - {total_issues} total harmonic issues") - log_message("") - log_message("RECOMMENDATION: Review bass notes and chord tones") - elif total_issues > 0: - log_message(f"STATUS: MINOR ISSUES FOUND") - log_message(f" - {total_issues} notes out of Am scale") - log_message("") - log_message("RECOMMENDATION: May be intentional chromatic passing tones") - else: - log_message(f"STATUS: HARMONICALLY COHERENT") - log_message(f" - All notes in Am scale") - log_message(f" - Bass follows root progression A-F-C-G") - log_message(f" - Chord tones align with progression") - - log_message("") - log_message("Agent 11 review complete.") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent17_sample_loader.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent17_sample_loader.py deleted file mode 100644 index ad8fec6..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent17_sample_loader.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Agent 17 - Sample Loading Reviewer -Verifies audio tracks have samples loaded and loads samples if needed. -""" -import socket -import json -import os -import glob -from datetime import datetime - -LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent17_review_samples.txt" -SAMPLE_LIBRARY = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks" -ORGANIZED_LIBRARY = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples" -HOST = "127.0.0.1" -PORT = 9877 - -def log(message): - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - log_line = f"[{timestamp}] {message}" - print(log_line) - os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(log_line + "\n") - -def send_command(command_type, params=None): - if params is None: - params = {} - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(30) - try: - sock.connect((HOST, PORT)) - request = {"type": command_type, "params": params} - sock.sendall((json.dumps(request) + "\n").encode("utf-8")) - response = b"" - while True: - chunk = sock.recv(4096) - if not chunk: - break - response += chunk - if b"\n" in response: - break - return json.loads(response.decode("utf-8").strip()) - finally: - sock.close() - -def find_samples(query, sample_type=None): - samples = [] - search_paths = [ORGANIZED_LIBRARY, SAMPLE_LIBRARY] - - for search_path in search_paths: - if not os.path.exists(search_path): - continue - - pattern = f"**/*{query}*.wav" - for filepath in glob.glob(os.path.join(search_path, pattern), recursive=True): - if sample_type: - type_dir = os.path.join(search_path, sample_type) - if type_dir.lower() in filepath.lower(): - samples.append(filepath) - else: - samples.append(filepath) - - return samples[:15] - -def load_samples_to_track(track_index, track_name, sample_type, positions): - samples = find_samples(sample_type) - if not samples: - log(f" No samples found for type: {sample_type}") - return 0 - - clips_loaded = 0 - for i, sample_path in enumerate(samples): - if clips_loaded >= 10: - break - - position = positions[i] if i < len(positions) else positions[-1] + (i - len(positions) + 1) * 4 - - try: - result = send_command("create_arrangement_audio_pattern", { - "track_index": track_index, - "file_path": sample_path, - "positions": [position], - "name": f"{track_name} Clip {i+1}" - }) - if result.get("status") == "success": - clips_loaded += 1 - log(f" Loaded: {os.path.basename(sample_path)} at position {position}") - else: - log(f" Failed: {result.get('message', 'Unknown error')}") - except Exception as e: - log(f" Error loading sample: {e}") - - return clips_loaded - -def main(): - log("=" * 60) - log("Agent 17 - Sample Loading Reviewer Started") - log("=" * 60) - - log("\n[1] Connecting to Ableton socket...") - try: - session = send_command("get_session_info", {}) - if session.get("status") != "success": - log(f"ERROR: Failed to get session info: {session}") - return - log(f"Connected. Tempo: {session.get('result', {}).get('tempo', 'unknown')} BPM") - except Exception as e: - log(f"ERROR: Cannot connect to Ableton: {e}") - return - - log("\n[2] Getting track list...") - try: - tracks_response = send_command("get_tracks", {}) - if tracks_response.get("status") != "success": - log(f"ERROR: Failed to get tracks: {tracks_response}") - return - tracks = tracks_response.get("result", []) - log(f"Found {len(tracks)} tracks") - except Exception as e: - log(f"ERROR: Cannot get tracks: {e}") - return - - log("\n[3] Analyzing audio tracks...") - audio_tracks_needing_samples = [] - - for track in tracks: - track_name = track.get("name", "") - track_index = track.get("index", -1) - has_audio = track.get("has_audio_input", False) and track.get("has_audio_output", False) - has_midi = track.get("has_midi_input", False) - arr_clips = track.get("arrangement_clip_count", 0) - - if has_audio and not has_midi: - if arr_clips < 10: - audio_tracks_needing_samples.append({ - "index": track_index, - "name": track_name, - "clips": arr_clips - }) - log(f" Track {track_index}: '{track_name}' - {arr_clips} clips (NEEDS SAMPLES)") - else: - log(f" Track {track_index}: '{track_name}' - {arr_clips} clips (OK)") - - if not audio_tracks_needing_samples: - log("\n[4] All audio tracks have sufficient samples!") - return - - log(f"\n[4] {len(audio_tracks_needing_samples)} tracks need samples. Loading...") - - track_type_map = { - "KICK": "kick", - "SNARE": "snare", - "HATS": "hat", - "HAT": "hat", - "BASS": "bass", - "LEAD": "synth", - "PAD": "atmos", - "ARP": "synth", - "PERC": "percussion", - "VOCAL": "vocal", - "RISER": "riser", - "CRASH": "crash", - "DOWNLIFTER": "fx", - "AUDIO": "synth" - } - - positions = [0, 8, 16, 24, 32, 40, 48, 56, 64, 72] - - for track_info in audio_tracks_needing_samples: - track_index = track_info["index"] - track_name = track_info["name"] - - sample_type = "synth" - for key, stype in track_type_map.items(): - if key in track_name.upper(): - sample_type = stype - break - - log(f"\n Loading {sample_type} samples into track {track_index} ('{track_name}')...") - clips_loaded = load_samples_to_track(track_index, track_name, sample_type, positions) - track_info["loaded"] = clips_loaded - - log("\n" + "=" * 60) - log("SUMMARY") - log("=" * 60) - for track_info in audio_tracks_needing_samples: - log(f" Track {track_info['index']} ('{track_info['name']}'): {track_info.get('clips', 0)} -> +{track_info.get('loaded', 0)} clips loaded") - - log("\nAgent 17 completed.") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent7_vocals.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent7_vocals.py deleted file mode 100644 index 5d1e4fe..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent7_vocals.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -""" -Agent 7 - VOCAL/CHOIR SPECIALIST -Loads vocal samples at specific arrangement positions -""" -import socket -import json -import sys - -HOST = "127.0.0.1" -PORT = 9877 - -VOCAL_MAIN_TRACK = 12 -VOCAL_TEXTURE_TRACK = 13 - -VOCAL_MAIN_SAMPLES = [ - r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\BBH- Primer Impacto - Vocal Quema D#m 126 Bpm.wav", - r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\vocal\BBH - Primer Impacto - Vocal Importante 1.wav", - r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\vocal\BBH - Primer Impacto - Vocal Importante 2.wav", - r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\vocal\BBH - Primer Impacto - Vocal Importante 3.wav", -] - -VOCAL_TEXTURE_SAMPLES = [ - r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_03_Am_125.wav", - r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_05_Cm_125.wav", - r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_08_Cm_125.wav", - r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\vocal\Vox_10_Bm_125.wav", -] - -VOCAL_MAIN_POSITIONS = [16.0, 48.0, 80.0, 112.0] -VOCAL_TEXTURE_POSITIONS = [0.0, 32.0, 64.0, 96.0] - -LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent7_vocals.txt" - -def send_command(command_type: str, params: dict = None, timeout: float = 45.0) -> dict: - payload = json.dumps({ - "type": command_type, - "params": params or {}, - }).encode("utf-8") + b"\n" - - with socket.create_connection((HOST, PORT), timeout=timeout) as sock: - sock.sendall(payload) - reader = sock.makefile("r", encoding="utf-8") - line = reader.readline() - if not line: - raise RuntimeError(f"No response for command: {command_type}") - return json.loads(line) - -def log(msg: str): - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(msg + "\n") - print(msg) - -def main(): - log("=" * 60) - log("AGENT 7 - VOCAL/CHOIR SPECIALIST") - log("=" * 60) - - # Step 1: Set input routing to "No Input" for both tracks - log("\n[STEP 1] Setting input routing to 'No Input'...") - - for track_idx, track_name in [(VOCAL_MAIN_TRACK, "VOCAL MAIN"), (VOCAL_TEXTURE_TRACK, "VOCAL TEXTURE")]: - try: - result = send_command("set_track_input_routing", {"index": track_idx, "routing_name": "No Input"}) - log(f" Track {track_idx} ({track_name}): {result}") - except Exception as e: - log(f" ERROR Track {track_idx}: {e}") - - # Step 2: Load VOCAL MAIN samples at key moments - log("\n[STEP 2] Loading VOCAL MAIN samples at key moments...") - - for i, (sample_path, position) in enumerate(zip(VOCAL_MAIN_SAMPLES, VOCAL_MAIN_POSITIONS)): - try: - result = send_command("create_arrangement_audio_pattern", { - "track_index": VOCAL_MAIN_TRACK, - "file_path": sample_path, - "positions": [position], - "name": f"Vocal Main {i+1}" - }) - log(f" Position {position}: {sample_path.split(chr(92))[-1]} -> {result.get('status', 'unknown')}") - except Exception as e: - log(f" ERROR at position {position}: {e}") - - # Step 3: Load VOCAL TEXTURE samples at atmospheric positions - log("\n[STEP 3] Loading VOCAL TEXTURE samples at atmospheric positions...") - - for i, (sample_path, position) in enumerate(zip(VOCAL_TEXTURE_SAMPLES, VOCAL_TEXTURE_POSITIONS)): - try: - result = send_command("create_arrangement_audio_pattern", { - "track_index": VOCAL_TEXTURE_TRACK, - "file_path": sample_path, - "positions": [position], - "name": f"Vocal Texture {i+1}" - }) - log(f" Position {position}: {sample_path.split(chr(92))[-1]} -> {result.get('status', 'unknown')}") - except Exception as e: - log(f" ERROR at position {position}: {e}") - - log("\n" + "=" * 60) - log("AGENT 7 COMPLETE - Vocal layers loaded") - log("=" * 60) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent8_fx_transitions.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent8_fx_transitions.py deleted file mode 100644 index b5c7561..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent8_fx_transitions.py +++ /dev/null @@ -1,102 +0,0 @@ -import json -import socket -from datetime import datetime - -LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent8_fx.txt" - -def log(msg): - timestamp = datetime.now().isoformat() - entry = f"[{timestamp}] {msg}" - print(entry) - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(entry + "\n") - -class AbletonSocketClient: - def __init__(self, host="127.0.0.1", port=9877, timeout=30.0): - self.host = host - self.port = port - self.timeout = timeout - - def send(self, command_type, params=None): - payload = json.dumps({ - "type": command_type, - "params": params or {}, - }).encode("utf-8") + b"\n" - - with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: - sock.sendall(payload) - reader = sock.makefile("r", encoding="utf-8") - try: - line = reader.readline() - finally: - reader.close() - try: - sock.shutdown(socket.SHUT_RDWR) - except OSError: - pass - - if not line: - raise RuntimeError(f"No response for command: {command_type}") - return json.loads(line) - -def main(): - log("=" * 60) - log("AGENT 8 - FX TRANSITION SPECIALIST") - log("=" * 60) - - client = AbletonSocketClient() - - RISER_TRACK = 16 - DOWNLIFTER_TRACK = 17 - CRASH_TRACK = 18 - - RISER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\fx\BBH - Primer Impacto -Risers 2.wav" - DOWNLIFTER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\fx\EFX_01_Em_125.wav" - CRASH_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\loops\fx\BBH - Primer Impacto - Crash 1.wav" - - RISER_POSITIONS = [14, 46, 78, 110, 142, 174] - DOWNLIFTER_POSITIONS = [16, 48, 80, 112, 144, 176] - CRASH_POSITIONS = [0, 32, 64, 96, 128, 160, 192] - - log(f"Track indices: RISER={RISER_TRACK}, DOWNLIFTER={DOWNLIFTER_TRACK}, CRASH={CRASH_TRACK}") - log(f"Riser positions: {RISER_POSITIONS}") - log(f"Downlifter positions: {DOWNLIFTER_POSITIONS}") - log(f"Crash positions: {CRASH_POSITIONS}") - - log("") - log("Step 1: Placing RISER samples...") - result = client.send("create_arrangement_audio_pattern", { - "track_index": RISER_TRACK, - "file_path": RISER_PATH, - "positions": RISER_POSITIONS, - "name": "RISER FX" - }) - log(f"RISER result: {json.dumps(result, indent=2)}") - - log("") - log("Step 2: Placing DOWNLIFTER samples (using EFX fallback)...") - result = client.send("create_arrangement_audio_pattern", { - "track_index": DOWNLIFTER_TRACK, - "file_path": DOWNLIFTER_PATH, - "positions": DOWNLIFTER_POSITIONS, - "name": "DOWNLIFTER FX" - }) - log(f"DOWNLIFTER result: {json.dumps(result, indent=2)}") - - log("") - log("Step 3: Placing CRASH samples...") - result = client.send("create_arrangement_audio_pattern", { - "track_index": CRASH_TRACK, - "file_path": CRASH_PATH, - "positions": CRASH_POSITIONS, - "name": "CRASH FX" - }) - log(f"CRASH result: {json.dumps(result, indent=2)}") - - log("") - log("=" * 60) - log("AGENT 8 COMPLETE") - log("=" * 60) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent9_perc_loader.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent9_perc_loader.py deleted file mode 100644 index bbbfce8..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/agent9_perc_loader.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -Agent 9 - PERCUSSION SPECIALIST -Loads percussion samples into AUDIO PERC MAIN and AUDIO PERC FX tracks. -""" -import json -import socket -import os -from datetime import datetime -from typing import Any, Dict, List - -LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\agent9_perc.txt" -HOST = "127.0.0.1" -PORT = 9877 -TIMEOUT = 30.0 - -PERC_MAIN_TRACK_INDEX = 14 -PERC_FX_TRACK_INDEX = 15 - -PERC_MAIN_POSITIONS = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176] -PERC_FX_POSITIONS = [4, 12, 20, 28, 36, 44, 52, 60] - -SAMPLE_BASE = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples" - -PERC_LOOP_SAMPLES = [ - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_01_Fm_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_02_Any_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_03_A#_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_04_Any_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_05_Any_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_06_Dm_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_07_Cm_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_08_Fm_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_09_Bm_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_10_Dm_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_11_Am_125.wav"), - os.path.join(SAMPLE_BASE, "loops", "perc", "Perc_Loop_12_Bm_125.wav"), -] - -PERC_FX_SAMPLES = [ - os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 2.wav"), - os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 3.wav"), - os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 1.wav"), - os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 2.wav"), - os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 3.wav"), - os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Bongos y Congas 4.wav"), - os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 6.wav"), - os.path.join(SAMPLE_BASE, "oneshots", "perc", "BBH - Primer Impacto - Shaker 8.wav"), -] - - -def log(msg: str): - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - line = f"[{timestamp}] {msg}" - print(line) - try: - os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(line + "\n") - except Exception as e: - print(f"Log write error: {e}") - - -class AbletonSocketClient: - def __init__(self, host: str = HOST, port: int = PORT, timeout: float = TIMEOUT): - self.host = host - self.port = port - self.timeout = timeout - - def send(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: - payload = json.dumps({ - "type": command_type, - "params": params or {}, - }).encode("utf-8") + b"\n" - - with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: - sock.sendall(payload) - reader = sock.makefile("r", encoding="utf-8") - try: - line = reader.readline() - finally: - reader.close() - try: - sock.shutdown(socket.SHUT_RDWR) - except OSError: - pass - - if not line: - raise RuntimeError(f"No response for command: {command_type}") - - return json.loads(line) - - -def set_input_routing(client: AbletonSocketClient, track_index: int, routing_name: str) -> bool: - try: - response = client.send("set_track_input_routing", { - "track_index": track_index, - "routing_name": routing_name, - }) - if response.get("status") == "success": - log(f"Set track {track_index} input routing to '{routing_name}'") - return True - else: - log(f"Failed to set input routing: {response.get('message', 'unknown error')}") - return False - except Exception as e: - log(f"Error setting input routing: {e}") - return False - - -def load_audio_pattern(client: AbletonSocketClient, track_index: int, file_path: str, positions: List[float], name: str = "") -> bool: - if not os.path.exists(file_path): - log(f"Sample not found: {file_path}") - return False - - try: - response = client.send("create_arrangement_audio_pattern", { - "track_index": track_index, - "file_path": file_path, - "positions": positions, - "name": name or os.path.basename(file_path), - }) - if response.get("status") == "success": - log(f"Loaded '{os.path.basename(file_path)}' at positions {positions[:3]}... on track {track_index}") - return True - else: - log(f"Failed to load audio: {response.get('message', 'unknown error')}") - return False - except Exception as e: - log(f"Error loading audio: {e}") - return False - - -def main(): - log("=" * 60) - log("AGENT 9 - PERCUSSION SPECIALIST STARTING") - log("=" * 60) - - client = AbletonSocketClient() - - log("Connecting to Ableton socket...") - try: - info = client.send("get_session_info", {}) - if info.get("status") != "success": - log("Failed to get session info") - return - log(f"Connected. BPM: {info.get('result', {}).get('tempo', 'unknown')}") - except Exception as e: - log(f"Connection failed: {e}") - return - - log("Setting input routing to 'No Input'...") - set_input_routing(client, PERC_MAIN_TRACK_INDEX, "No Input") - set_input_routing(client, PERC_FX_TRACK_INDEX, "No Input") - - log("") - log("Loading PERC MAIN loops...") - main_loaded = 0 - for i, pos in enumerate(PERC_MAIN_POSITIONS): - if i < len(PERC_LOOP_SAMPLES): - sample = PERC_LOOP_SAMPLES[i] - if load_audio_pattern(client, PERC_MAIN_TRACK_INDEX, sample, [float(pos)], f"PERC_LOOP_{i+1}"): - main_loaded += 1 - - log(f"PERC MAIN: {main_loaded}/{len(PERC_MAIN_POSITIONS)} samples loaded") - - log("") - log("Loading PERC FX hits...") - fx_loaded = 0 - for i, pos in enumerate(PERC_FX_POSITIONS): - if i < len(PERC_FX_SAMPLES): - sample = PERC_FX_SAMPLES[i] - if load_audio_pattern(client, PERC_FX_TRACK_INDEX, sample, [float(pos)], f"PERC_FX_{i+1}"): - fx_loaded += 1 - - log(f"PERC FX: {fx_loaded}/{len(PERC_FX_POSITIONS)} samples loaded") - - log("") - log("=" * 60) - log(f"AGENT 9 COMPLETE: MAIN={main_loaded}, FX={fx_loaded}") - log("=" * 60) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_analyzer.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_analyzer.py deleted file mode 100644 index 29feefa..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_analyzer.py +++ /dev/null @@ -1,681 +0,0 @@ -""" -audio_analyzer.py - Análisis de audio para detección de Key y BPM - -Proporciona análisis básico de archivos de audio para extraer: -- BPM (tempo) mediante detección de onset y autocorrelación -- Key (tonalidad) mediante análisis de cromagrama -- Características espectrales para clasificación -""" - -import os -import logging -import numpy as np -import subprocess -from pathlib import Path -from typing import Dict, Any, Optional, Tuple, List -from dataclasses import dataclass -from enum import Enum - -logger = logging.getLogger("AudioAnalyzer") - -# Constantes musicales -NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] -KEY_PROFILES = { - # Perfiles de Krumhansl-Schmuckler para detección de tonalidad - 'major': [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': [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17] -} - -CIRCLE_OF_FIFTHS_MAJOR = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F'] -CIRCLE_OF_FIFTHS_MINOR = ['Am', 'Em', 'Bm', 'F#m', 'C#m', 'G#m', 'D#m', 'A#m', 'Fm', 'Cm', 'Gm', 'Dm'] - - -class SampleType(Enum): - """Tipos de samples musicales""" - KICK = "kick" - SNARE = "snare" - CLAP = "clap" - HAT_CLOSED = "hat_closed" - HAT_OPEN = "hat_open" - HAT = "hat" - PERC = "perc" - SHAKER = "shaker" - TOM = "tom" - CRASH = "crash" - RIDE = "ride" - BASS = "bass" - SYNTH = "synth" - PAD = "pad" - LEAD = "lead" - PLUCK = "pluck" - ARP = "arp" - CHORD = "chord" - STAB = "stab" - VOCAL = "vocal" - FX = "fx" - LOOP = "loop" - AMBIENCE = "ambience" - UNKNOWN = "unknown" - - -@dataclass -class AudioFeatures: - """Características extraídas de un archivo de audio""" - bpm: Optional[float] - key: Optional[str] - key_confidence: float - duration: float - sample_rate: int - sample_type: SampleType - spectral_centroid: float - spectral_rolloff: float - zero_crossing_rate: float - rms_energy: float - is_harmonic: bool - is_percussive: bool - suggested_genres: List[str] - - -class AudioAnalyzer: - """ - Analizador de audio para samples musicales. - - Soporta múltiples backends: - - librosa (recomendado, más preciso) - - basic (fallback sin dependencias externas, basado en nombre de archivo) - """ - - def __init__(self, backend: str = "auto"): - """ - Inicializa el analizador de audio. - - Args: - backend: 'librosa', 'basic', o 'auto' (detecta automáticamente) - """ - self.backend = backend - self._librosa_available = False - self._soundfile_available = False - - if backend in ("auto", "librosa"): - self._check_librosa() - - if self._librosa_available: - logger.info("Usando backend: librosa") - else: - logger.info("Usando backend: basic (análisis por nombre de archivo)") - - def _check_librosa(self): - """Verifica si librosa está disponible""" - try: - import librosa - import soundfile as sf - self._librosa_available = True - self._soundfile_available = True - self.librosa = librosa - self.sf = sf - except ImportError: - self._librosa_available = False - self._soundfile_available = False - - def analyze(self, file_path: str) -> AudioFeatures: - """ - Analiza un archivo de audio y extrae características. - - Args: - file_path: Ruta al archivo de audio - - Returns: - AudioFeatures con los datos extraídos - """ - path = Path(file_path) - - if not path.exists(): - raise FileNotFoundError(f"Archivo no encontrado: {file_path}") - - # Intentar análisis con librosa si está disponible - if self._librosa_available: - try: - return self._analyze_with_librosa(file_path) - except Exception as e: - logger.warning(f"Error con librosa: {e}, usando análisis básico") - - # Fallback a análisis básico - return self._analyze_basic(file_path) - - def _analyze_with_librosa(self, file_path: str) -> AudioFeatures: - """Análisis completo usando librosa""" - # Cargar audio - y, sr = self.librosa.load(file_path, sr=None, mono=True) - - # Duración - duration = self.librosa.get_duration(y=y, sr=sr) - - # Detectar BPM - tempo, _ = self.librosa.beat.beat_track(y=y, sr=sr) - bpm = float(tempo) if isinstance(tempo, (int, float, np.number)) else None - - # Análisis espectral - spectral_centroids = self.librosa.feature.spectral_centroid(y=y, sr=sr)[0] - spectral_rolloffs = self.librosa.feature.spectral_rolloff(y=y, sr=sr)[0] - zcr = self.librosa.feature.zero_crossing_rate(y)[0] - rms = self.librosa.feature.rms(y=y)[0] - - # Detectar key - key, key_confidence = self._detect_key_librosa(y, sr) - - # Clasificación percusivo vs armónico - is_percussive = self._is_percussive(y, sr) - is_harmonic = not is_percussive and duration > 1.0 - - # Determinar tipo de sample - sample_type = self._classify_sample_type( - file_path, is_percussive, is_harmonic, duration, - float(np.mean(spectral_centroids)), float(np.mean(rms)) - ) - - # Sugerir géneros - suggested_genres = self._suggest_genres(sample_type, bpm, key) - - return AudioFeatures( - bpm=bpm, - key=key, - key_confidence=key_confidence, - duration=duration, - sample_rate=sr, - sample_type=sample_type, - spectral_centroid=float(np.mean(spectral_centroids)), - spectral_rolloff=float(np.mean(spectral_rolloffs)), - zero_crossing_rate=float(np.mean(zcr)), - rms_energy=float(np.mean(rms)), - is_harmonic=is_harmonic, - is_percussive=is_percussive, - suggested_genres=suggested_genres - ) - - def _detect_key_librosa(self, y: np.ndarray, sr: int) -> Tuple[Optional[str], float]: - """ - Detecta la tonalidad usando cromagrama y correlación con perfiles. - """ - try: - # Calcular cromagrama - chroma = self.librosa.feature.chroma_stft(y=y, sr=sr) - chroma_avg = np.mean(chroma, axis=1) - - # Normalizar - chroma_avg = chroma_avg / (np.sum(chroma_avg) + 1e-10) - - best_key = None - best_score = -np.inf - best_mode = None - - # Probar todas las tonalidades mayores y menores - for mode, profile in KEY_PROFILES.items(): - for i in range(12): - # Rotar el perfil - rotated_profile = np.roll(profile, i) - # Correlación - score = np.corrcoef(chroma_avg, rotated_profile)[0, 1] - - if score > best_score: - best_score = score - best_mode = mode - best_key = NOTE_NAMES[i] - - # Formatear resultado - if best_key: - if best_mode == 'minor': - best_key = best_key + 'm' - confidence = max(0.0, min(1.0, (best_score + 1) / 2)) - return best_key, confidence - - except Exception as e: - logger.warning(f"Error detectando key: {e}") - - return None, 0.0 - - def _is_percussive(self, y: np.ndarray, sr: int) -> bool: - """ - Determina si un sonido es principalmente percusivo. - """ - try: - # Separar componentes armónicos y percusivos - y_harmonic, y_percussive = self.librosa.effects.hpss(y) - - # Calcular energía relativa - energy_harmonic = np.sum(y_harmonic ** 2) - energy_percussive = np.sum(y_percussive ** 2) - total_energy = energy_harmonic + energy_percussive - - if total_energy > 0: - percussive_ratio = energy_percussive / total_energy - return percussive_ratio > 0.6 - - except Exception as e: - logger.warning(f"Error en separación HPSS: {e}") - - # Fallback: usar duración como heurística - duration = len(y) / sr - return duration < 0.5 - - def _analyze_basic(self, file_path: str) -> AudioFeatures: - """ - Análisis básico sin dependencias externas. - Usa metadatos del archivo y nombre para inferir características. - """ - path = Path(file_path) - name = path.stem - - # Extraer del nombre - bpm = self._extract_bpm_from_name(name) - key = self._extract_key_from_name(name) - - # Estimar duración del archivo - duration = self._estimate_duration(file_path) - - # Clasificar por nombre - sample_type = self._classify_by_name(name) - - # Determinar características por tipo - is_percussive = sample_type in [ - SampleType.KICK, SampleType.SNARE, SampleType.CLAP, - SampleType.HAT, SampleType.HAT_CLOSED, SampleType.HAT_OPEN, - SampleType.PERC, SampleType.SHAKER, SampleType.TOM, - SampleType.CRASH, SampleType.RIDE - ] - is_harmonic = sample_type in [ - SampleType.BASS, SampleType.SYNTH, SampleType.PAD, - SampleType.LEAD, SampleType.PLUCK, SampleType.CHORD, - SampleType.VOCAL - ] - - # Valores por defecto basados en tipo - spectral_centroid = 5000.0 if is_percussive else 1000.0 - rms_energy = 0.5 - - suggested_genres = self._suggest_genres(sample_type, bpm, key) - - return AudioFeatures( - bpm=bpm, - key=key, - key_confidence=0.7 if key else 0.0, - duration=duration, - sample_rate=44100, - sample_type=sample_type, - spectral_centroid=spectral_centroid, - spectral_rolloff=spectral_centroid * 2, - zero_crossing_rate=0.1 if is_harmonic else 0.3, - rms_energy=rms_energy, - is_harmonic=is_harmonic, - is_percussive=is_percussive, - suggested_genres=suggested_genres - ) - - def _estimate_duration(self, file_path: str) -> float: - """Estima la duración del archivo de audio""" - try: - import wave - - ext = Path(file_path).suffix.lower() - - if ext == '.wav': - with wave.open(file_path, 'rb') as wav: - frames = wav.getnframes() - rate = wav.getframerate() - return frames / float(rate) - - elif ext in ('.mp3', '.ogg', '.flac', '.aif', '.aiff', '.m4a'): - windows_duration = self._estimate_duration_with_windows_shell(file_path) - if windows_duration > 0: - return windows_duration - # Estimación por tamaño de archivo - size = os.path.getsize(file_path) - # Aproximación: ~176KB por segundo para CD quality stereo - return size / (176.4 * 1024) - - except Exception as e: - logger.warning(f"Error estimando duración: {e}") - - return 0.0 - - def _estimate_duration_with_windows_shell(self, file_path: str) -> float: - """Obtiene la duración usando metadatos del shell de Windows cuando están disponibles.""" - if os.name != 'nt': - return 0.0 - - safe_path = file_path.replace("'", "''") - powershell_command = ( - f"$path = '{safe_path}'; " - "$shell = New-Object -ComObject Shell.Application; " - "$folder = $shell.Namespace((Split-Path $path)); " - "$file = $folder.ParseName((Split-Path $path -Leaf)); " - "$duration = $folder.GetDetailsOf($file, 27); " - "Write-Output $duration" - ) - try: - result = subprocess.run( - f'powershell -NoProfile -Command "{powershell_command}"', - capture_output=True, - text=True, - timeout=5, - check=False, - shell=True, - ) - value = (result.stdout or "").strip() - if not value: - return 0.0 - parts = value.split(':') - if len(parts) == 3: - return (int(parts[0]) * 3600) + (int(parts[1]) * 60) + float(parts[2]) - return 0.0 - except Exception: - return 0.0 - - def _extract_bpm_from_name(self, name: str) -> Optional[float]: - """Extrae BPM del nombre del archivo""" - import re - - patterns = [ - r'[_\s\-](\d{2,3})\s*BPM', - r'[_\s\-](\d{2,3})[_\s\-]', - r'(\d{2,3})bpm', - r'[_\s\-](\d{2,3})\s*(?:BPM|bpm)?\s*(?:\.wav|\.mp3|\.aif)', - ] - - for pattern in patterns: - match = re.search(pattern, name, re.IGNORECASE) - if match: - bpm = int(match.group(1)) - if 60 <= bpm <= 200: - return float(bpm) - - return None - - def _extract_key_from_name(self, name: str) -> Optional[str]: - """Extrae key del nombre del archivo""" - import re - - patterns = [ - r'[_\s\-]([A-G][#b]?(?:m|min|minor)?)[_\s\-]', - r'\bin\s+([A-G][#b]?(?:m|min|minor)?)\b', - r'Key\s+([A-G][#b]?(?:m|min|minor)?)', - r'[_\s\-]([A-G][#b]?)\s*(?:maj|major)?[_\s\-]', - ] - - for pattern in patterns: - match = re.search(pattern, name, re.IGNORECASE) - if match: - key = match.group(1) - # Normalizar - key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') - key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') - - # Detectar si es menor - is_minor = 'm' in key.lower() or 'min' in key.lower() - key = key.replace('min', '').replace('minor', '').replace('major', '') - key = key.rstrip('mM') - - if is_minor: - key = key + 'm' - - return key - - return None - - def _classify_sample_type(self, file_path: str, is_percussive: bool, - is_harmonic: bool, duration: float, - spectral_centroid: float, rms: float) -> SampleType: - """Clasifica el tipo de sample basado en características""" - # Primero intentar por nombre - sample_type = self._classify_by_name(Path(file_path).stem) - if sample_type != SampleType.UNKNOWN: - return sample_type - - # Clasificación por características de audio - if is_percussive: - if duration < 0.1: - if spectral_centroid < 2000: - return SampleType.KICK - elif spectral_centroid > 8000: - return SampleType.HAT_CLOSED - else: - return SampleType.SNARE - elif duration < 0.3: - return SampleType.CLAP - else: - return SampleType.PERC - - elif is_harmonic: - if spectral_centroid < 500: - return SampleType.BASS - elif duration > 4.0: - return SampleType.PAD - else: - return SampleType.SYNTH - - return SampleType.UNKNOWN - - def _classify_by_name(self, name: str) -> SampleType: - """Clasifica el tipo de sample basado en su nombre""" - name_lower = name.lower() - - # Mapeo de palabras clave a tipos - keywords = { - SampleType.KICK: ['kick', 'bd', 'bass drum', 'kickdrum', 'kik'], - SampleType.SNARE: ['snare', 'snr', 'sd', 'rim'], - SampleType.CLAP: ['clap', 'clp', 'handclap'], - SampleType.HAT_CLOSED: ['closed hat', 'closedhat', 'chh', 'closed'], - SampleType.HAT_OPEN: ['open hat', 'openhat', 'ohh', 'open'], - SampleType.HAT: ['hat', 'hihat', 'hi-hat', 'hh'], - SampleType.PERC: ['perc', 'percussion', 'conga', 'bongo', 'timb'], - SampleType.SHAKER: ['shaker', 'shake', 'tamb'], - SampleType.TOM: ['tom', 'tomtom'], - SampleType.CRASH: ['crash', 'cymbal'], - SampleType.RIDE: ['ride'], - SampleType.BASS: ['bass', 'bassline', 'sub', '808', 'reese'], - SampleType.SYNTH: ['synth', 'lead', 'arp', 'sequence'], - SampleType.PAD: ['pad', 'atmosphere', 'dron'], - SampleType.PLUCK: ['pluck'], - SampleType.CHORD: ['chord', 'stab'], - SampleType.VOCAL: ['vocal', 'vox', 'voice', 'speech', 'talk'], - SampleType.FX: ['fx', 'effect', 'sweep', 'riser', 'downlifter', 'impact', 'hit', 'noise'], - SampleType.LOOP: ['loop', 'full', 'groove'], - } - - for sample_type, words in keywords.items(): - for word in words: - if word in name_lower: - return sample_type - - return SampleType.UNKNOWN - - def _suggest_genres(self, sample_type: SampleType, bpm: Optional[float], - key: Optional[str]) -> List[str]: - """Sugiere géneros musicales apropiados para el sample""" - genres = [] - - if bpm: - if 118 <= bpm <= 128: - genres.extend(['house', 'tech-house', 'deep-house']) - elif 124 <= bpm <= 132: - genres.extend(['tech-house', 'techno']) - elif 132 <= bpm <= 142: - genres.extend(['techno', 'peak-time-techno']) - elif 142 <= bpm <= 150: - genres.extend(['trance', 'hard-techno']) - elif 160 <= bpm <= 180: - genres.extend(['drum-and-bass', 'neurofunk']) - elif bpm < 118: - genres.extend(['downtempo', 'ambient', 'lo-fi']) - - # Por tipo de sample - if sample_type in [SampleType.KICK, SampleType.SNARE, SampleType.CLAP]: - if not genres: - genres = ['techno', 'house'] - elif sample_type == SampleType.BASS: - if not genres: - genres = ['techno', 'house', 'bass-music'] - elif sample_type in [SampleType.SYNTH, SampleType.PAD]: - if not genres: - genres = ['trance', 'progressive', 'ambient'] - - return genres if genres else ['electronic'] - - def get_compatible_key(self, key: str, shift: int = 0) -> str: - """ - Obtiene una key compatible usando el círculo de quintas. - - Args: - key: Key original (ej: 'Am', 'F#m') - shift: Desplazamiento en el círculo (+1 = quinta arriba, -1 = quinta abajo) - - Returns: - Key resultante - """ - is_minor = key.endswith('m') - root = key.rstrip('m') - - if root not in NOTE_NAMES: - return key - - circle = CIRCLE_OF_FIFTHS_MINOR if is_minor else CIRCLE_OF_FIFTHS_MAJOR - - try: - idx = circle.index(key) - new_idx = (idx + shift) % 12 - return circle[new_idx] - except ValueError: - return key - - def calculate_key_compatibility(self, key1: str, key2: str) -> float: - """ - Calcula la compatibilidad entre dos keys (0-1). - - Usa el círculo de quintas: keys cercanas son más compatibles. - """ - if key1 == key2: - return 1.0 - - # Normalizar - def normalize(k): - is_minor = k.endswith('m') - root = k.rstrip('m') - # Convertir bemoles a sostenidos - root = root.replace('Db', 'C#').replace('Eb', 'D#') - root = root.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') - return root + ('m' if is_minor else '') - - k1 = normalize(key1) - k2 = normalize(key2) - - if k1 == k2: - return 1.0 - - # Verificar si son modos diferentes de la misma nota - if k1.rstrip('m') == k2.rstrip('m'): - return 0.8 # Mismo root, diferente modo - - # Usar círculo de quintas - is_minor1 = k1.endswith('m') - is_minor2 = k2.endswith('m') - - if is_minor1 != is_minor2: - return 0.3 # Diferente modo, baja compatibilidad - - circle = CIRCLE_OF_FIFTHS_MINOR if is_minor1 else CIRCLE_OF_FIFTHS_MAJOR - - try: - idx1 = circle.index(k1) - idx2 = circle.index(k2) - distance = min(abs(idx1 - idx2), 12 - abs(idx1 - idx2)) - - # Compatibilidad decrece con la distancia - compatibility = max(0.0, 1.0 - (distance * 0.2)) - return compatibility - - except ValueError: - return 0.0 - - -# Instancia global -_analyzer: Optional[AudioAnalyzer] = None - - -def get_analyzer() -> AudioAnalyzer: - """Obtiene la instancia global del analizador""" - global _analyzer - if _analyzer is None: - _analyzer = AudioAnalyzer() - return _analyzer - - -def analyze_sample(file_path: str) -> Dict[str, Any]: - """ - Función de conveniencia para analizar un sample. - - Returns: - Diccionario con las características del sample - """ - analyzer = get_analyzer() - features = analyzer.analyze(file_path) - - return { - 'bpm': features.bpm, - 'key': features.key, - 'key_confidence': features.key_confidence, - 'duration': features.duration, - 'sample_rate': features.sample_rate, - 'sample_type': features.sample_type.value, - 'spectral_centroid': features.spectral_centroid, - 'rms_energy': features.rms_energy, - 'is_harmonic': features.is_harmonic, - 'is_percussive': features.is_percussive, - 'suggested_genres': features.suggested_genres, - } - - -def quick_analyze(file_path: str) -> Dict[str, Any]: - """ - Análisis rápido basado solo en el nombre del archivo. - No requiere dependencias externas. - """ - analyzer = AudioAnalyzer(backend="basic") - features = analyzer.analyze(file_path) - - return { - 'bpm': features.bpm, - 'key': features.key, - 'sample_type': features.sample_type.value, - 'suggested_genres': features.suggested_genres, - } - - -# Testing -if __name__ == "__main__": - import sys - - logging.basicConfig(level=logging.INFO) - - if len(sys.argv) < 2: - print("Uso: python audio_analyzer.py ") - sys.exit(1) - - file_path = sys.argv[1] - - print(f"\nAnalizando: {file_path}") - print("=" * 50) - - try: - result = analyze_sample(file_path) - - print("\nResultados:") - print(f" BPM: {result['bpm'] or 'No detectado'}") - print(f" Key: {result['key'] or 'No detectado'} (confianza: {result['key_confidence']:.2f})") - print(f" Duración: {result['duration']:.2f}s") - print(f" Tipo: {result['sample_type']}") - print(f" Géneros sugeridos: {', '.join(result['suggested_genres'])}") - print(f" Es percusivo: {result['is_percussive']}") - print(f" Es armónico: {result['is_harmonic']}") - - except Exception as e: - print(f"Error: {e}") - sys.exit(1) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_resampler.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_resampler.py deleted file mode 100644 index ec23c7f..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/audio_resampler.py +++ /dev/null @@ -1,2466 +0,0 @@ -""" -audio_resampler.py - Deriva transiciones y FX propios desde los samples elegidos. - -Phase 1 Improvements: -- Cache robusto con invalidacion por mtime, size y edad maxima -- Crossfades equal-power para eliminar clicks -- HPF/LPF sweeps suaves con overlap-add y filtros butterworth de 4to orden -- Normalizacion con soft limiting mejorado (curva cubica + lookahead) -""" - -from __future__ import annotations - -import hashlib -import logging -import os -import time -from collections import OrderedDict -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import numpy as np - -try: - import soundfile as sf -except ImportError: # pragma: no cover - sf = None - -try: - import librosa -except ImportError: # pragma: no cover - librosa = None - -try: - from scipy import signal as scipy_signal -except ImportError: # pragma: no cover - scipy_signal = None - - -logger = logging.getLogger("AudioResampler") - - -def _safe_float(value: Any, default: float = 0.0) -> float: - try: - return float(value) - except Exception: - return float(default) - - -def _section_offsets(sections: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float, float]]: - offsets: List[Tuple[Dict[str, Any], float, float]] = [] - cursor = 0.0 - for section in sections: - beats = _safe_float(section.get("beats", 0.0), _safe_float(section.get("bars", 8), 8.0) * 4.0) - start = float(cursor) - end = float(cursor + max(1.0, beats)) - offsets.append((section, start, end)) - cursor = end - return offsets - - -def _samples_from_seconds(seconds: float, sample_rate: int, min_samples: int = 256) -> int: - """Convierte segundos a samples con minimo garantizado. - - Args: - seconds: Duracion en segundos - sample_rate: Tasa de muestreo en Hz - min_samples: Minimo de samples a retornar (default: 256) - - Returns: - Numero de samples con minimo garantizado - """ - return max(min_samples, int(round(seconds * sample_rate))) - - -def _seconds_from_samples(samples: int, sample_rate: int, min_duration: float = 0.05) -> float: - """Convierte samples a segundos. - - Args: - samples: Numero de samples - sample_rate: Tasa de muestreo en Hz - min_duration: Duracion minima en segundos si samples es 0 (default: 0.05) - - Returns: - Duracion en segundos - """ - return samples / sample_rate if samples > 0 else min_duration - - - -def _ensure_2d_float(audio: np.ndarray) -> np.ndarray: - """Asegura que el array sea 2D float32 (samples, channels).""" - if audio is None or audio.size == 0: - return np.zeros((1, 1), dtype=np.float32) - audio = np.asarray(audio, dtype=np.float32) - if audio.ndim == 1: - audio = audio.reshape(-1, 1) - return audio - - -def _safe_slice(audio: np.ndarray, start: int, end: int) -> np.ndarray: - """Extrae slice seguro que nunca retorna array vacio.""" - if audio is None or audio.size == 0: - channels = audio.shape[1] if (audio is not None and audio.ndim == 2) else 1 - return np.zeros((1, channels), dtype=np.float32) - start = max(0, min(start, audio.shape[0] - 1)) - end = max(start + 1, min(end, audio.shape[0])) - result = audio[start:end] - if result.size == 0: - return np.zeros((1, audio.shape[1]), dtype=np.float32) - return result - - -def _validate_mix_shapes(a: np.ndarray, b: np.ndarray) -> Tuple[bool, str]: - """Valida que dos arrays puedan mezclarse (broadcast compatible).""" - if a is None or b is None: - return False, "None array" - if a.size == 0 or b.size == 0: - return False, f"Empty array: a.shape={a.shape}, b.shape={b.shape}" - if a.ndim != b.ndim: - return False, f"Dimension mismatch: {a.ndim} vs {b.ndim}" - if a.shape[1] != b.shape[1]: - return False, f"Channel mismatch: {a.shape[1]} vs {b.shape[1]}" - return True, "OK" - - -class AudioResampler: - """Procesa audio para generar transiciones y FX. - - Phase 1 Improvements: - - Cache LRU con invalidacion por mtime, size y edad maxima - - Estadisticas de cache (hits/misses) - - Crossfades equal-power para mejor calidad - - HPF/LPF sweeps con filtros butterworth de 4to orden - - Soft limiting mejorado con curva cubica - """ - - # Limite maximo de archivos en cache - _CACHE_LIMIT: int = 50 - - # Edad maxima de cache en segundos (30 minutos) - _CACHE_MAX_AGE_S: float = 1800.0 - - # Tamanio maximo de cache en bytes (~500MB por defecto) - _CACHE_MAX_SIZE_BYTES: int = 500 * 1024 * 1024 - - # Valor de peak unificado para todos los renders (85% headroom) - _DEFAULT_PEAK: float = 0.85 - - # Crossfade samples por defecto (10ms a 44.1kHz) - _DEFAULT_CROSSFADE_SAMPLES: int = 441 - - # Minimos absolutos para evitar arrays vacios en procesamiento - _MIN_SAMPLES_FOR_FFT: int = 512 # Minimo para analisis espectral - _MIN_SAMPLES_FOR_WINDOW: int = 64 # Minimo para aplicar ventana - _MIN_SAMPLES_FOR_STRETCH: int = 100 # Minimo para time-stretch - _MIN_SAMPLES_FOR_SLICE: int = 32 # Minimo para slice de stutter - _MIN_SAMPLES_FOR_EFFECT: int = 256 # Minimo para aplicar cualquier efecto - _MIN_AUDIO_DURATION_S: float = 0.05 # 50ms minimo de audio - - def __init__(self, output_dir: Optional[str] = None, sample_rate: int = 44100): - local_root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) - self.output_dir = Path(output_dir) if output_dir else local_root / "AbletonMCP_AI" / "generated_audio" - self.output_dir.mkdir(parents=True, exist_ok=True) - self.sample_rate = max(1, int(sample_rate)) # Validacion defensiva - - # Cache LRU para audio cargado: path::mtime_ns::size -> (audio_array, sample_rate, timestamp) - # El mtime_ns es parte de la key para invalidacion automatica por modificacion - # timestamp se usa para invalidacion por edad maxima - self._audio_cache: OrderedDict[str, Tuple[np.ndarray, int, float]] = OrderedDict() - - # Metadatos de cache para tracking de memoria - self._cache_sizes: Dict[str, int] = {} # path -> bytes - self._cache_total_bytes: int = 0 - - # Estadisticas de cache - self._cache_hits: int = 0 - self._cache_misses: int = 0 - - def _validate_audio_array(self, audio: np.ndarray, context: str = "audio") -> np.ndarray: - """Valida y normaliza un array de audio. - - Args: - audio: Array a validar - context: Descripcion del contexto para mensajes de error - - Returns: - Array validado como float32 y al menos 2D - - Raises: - ValueError: Si el array esta vacio o es invalido - """ - if audio is None: - raise ValueError(f"{context}: audio es None") - - audio = np.asarray(audio, dtype=np.float32) - - if audio.size == 0: - raise ValueError(f"{context}: audio array esta vacio") - - # Asegurar que sea 2D (samples, channels) - if audio.ndim == 1: - audio = audio.reshape(-1, 1) - - return audio - - def _validate_positive(self, value: float, name: str) -> float: - """Valida que un valor sea positivo. - - Args: - value: Valor a validar - name: Nombre del parametro para mensaje de error - - Returns: - Valor validado como float - - Raises: - ValueError: Si el valor no es positivo - """ - try: - val = float(value) - except (TypeError, ValueError): - raise ValueError(f"{name}: debe ser un numero valido, recibido {value!r}") - - if val <= 0: - raise ValueError(f"{name}: debe ser positivo, recibido {val}") - - return val - - def _get_cache_key(self, file_path: str, mtime_ns: Optional[int] = None, file_size: Optional[int] = None) -> str: - """Genera key de cache a partir del path absoluto, mtime y size. - - Args: - file_path: Ruta al archivo - mtime_ns: Tiempo de modificacion en nanosegundos (opcional) - file_size: Tamanio del archivo en bytes (opcional) - - Returns: - Key unica que incluye mtime y size si se proporcionan - """ - base_key = str(Path(file_path).resolve()) - parts = [base_key] - if mtime_ns is not None: - parts.append(str(mtime_ns)) - if file_size is not None: - parts.append(str(file_size)) - return "::".join(parts) - - def _cache_get(self, key: str) -> Optional[Tuple[np.ndarray, int]]: - """Obtiene audio del cache (LRU: mueve al final si existe). - - Returns: - Tupla (audio_array, sample_rate) o None si no existe o expiro - """ - if key not in self._audio_cache: - self._cache_misses += 1 - return None - - cached_data = self._audio_cache[key] - # Nuevo formato: (audio, sample_rate, timestamp) - if len(cached_data) == 3: - audio, sample_rate, timestamp = cached_data - # Verificar edad maxima - if time.time() - timestamp > self._CACHE_MAX_AGE_S: - logger.debug("Cache entry expired by age: %s", key) - self._evict_cache_entry(key) - self._cache_misses += 1 - return None - else: - # Formato legacy: (audio, sample_rate) - audio, sample_rate = cached_data[:2] - - # Mover al final (mas reciente) - self._audio_cache.move_to_end(key) - self._cache_hits += 1 - return (audio, sample_rate) - - def _evict_cache_entry(self, key: str) -> None: - """Evict una entrada especifica del cache y actualiza contadores.""" - if key in self._audio_cache: - if key in self._cache_sizes: - self._cache_total_bytes -= self._cache_sizes[key] - del self._cache_sizes[key] - del self._audio_cache[key] - - def _cache_put(self, key: str, audio: np.ndarray, sample_rate: int) -> None: - """Agrega audio al cache con limite LRU y de memoria.""" - # Calcular tamanio en bytes - entry_size = audio.nbytes - - # Si ya existe, actualizar y mover al final - if key in self._audio_cache: - old_size = self._cache_sizes.get(key, 0) - self._cache_total_bytes -= old_size - self._cache_sizes[key] = entry_size - self._cache_total_bytes += entry_size - self._audio_cache[key] = (audio, sample_rate, time.time()) - self._audio_cache.move_to_end(key) - return - - # Evict entries si excede limite de memoria - while (self._cache_total_bytes + entry_size > self._CACHE_MAX_SIZE_BYTES - and len(self._audio_cache) > 0): - oldest_key = next(iter(self._audio_cache)) - self._evict_cache_entry(oldest_key) - logger.debug("Evicted cache entry (memory limit): %s", oldest_key) - - # Si el cache esta lleno por cantidad, eliminar el mas antiguo (primero) - while len(self._audio_cache) >= self._CACHE_LIMIT: - oldest_key = next(iter(self._audio_cache)) - self._evict_cache_entry(oldest_key) - logger.debug("Evicted cache entry (count limit): %s", oldest_key) - - # Agregar nueva entrada - self._cache_sizes[key] = entry_size - self._cache_total_bytes += entry_size - self._audio_cache[key] = (audio, sample_rate, time.time()) - - def _load_audio(self, file_path: str) -> Tuple[np.ndarray, int]: - """Carga un archivo de audio con cache LRU e invalidacion por mtime, size y edad. - - Args: - file_path: Ruta al archivo de audio - - Returns: - Tupla (audio_array, sample_rate) - - Raises: - RuntimeError: Si no se puede leer el archivo - """ - if not file_path: - raise RuntimeError("file_path esta vacio") - - path = Path(file_path) - - if not path.exists(): - raise RuntimeError(f"Archivo no encontrado: {path}") - - # Obtener mtime y size antes de cualquier operacion - stat_info = path.stat() - mtime_ns = stat_info.st_mtime_ns - file_size = stat_info.st_size - cache_key = self._get_cache_key(file_path, mtime_ns, file_size) - - # Intentar obtener del cache (la key incluye mtime y size, si cambio no se encontrara) - cached = self._cache_get(cache_key) - if cached is not None: - duration_s = len(cached[0]) / cached[1] - logger.debug("Cache hit for %s (sample_rate=%d, duration=%.2fs, hits=%d, misses=%d)", - path.name, cached[1], duration_s, self._cache_hits, self._cache_misses) - # Devolver copia para evitar mutaciones - return np.array(cached[0], dtype=np.float32, copy=True), cached[1] - - logger.debug("Cache miss for %s, reading from disk (hits=%d, misses=%d)", - path.name, self._cache_hits, self._cache_misses) - - if sf is not None: - try: - audio, sample_rate = sf.read(str(path), always_2d=True, dtype="float32") - - # Validacion defensiva - verificar que no este vacio - if audio.size == 0: - logger.warning("AUDIO_LOAD: fallback to silence (empty audio from %s)", path.name) - silence = np.zeros((int(self.sample_rate), 2), dtype=np.float32) - return silence, self.sample_rate - - duration_s = len(audio) / sample_rate - logger.debug("Loaded from disk via soundfile: %s (sample_rate=%d, duration=%.2fs, channels=%d)", - path.name, sample_rate, duration_s, audio.shape[1]) - - if sample_rate != self.sample_rate: - logger.debug("Resampling %s from %d to %d Hz", path.name, sample_rate, self.sample_rate) - audio = self._resample_audio(audio, sample_rate, self.sample_rate) - sample_rate = self.sample_rate - - # Guardar en cache - self._cache_put(cache_key, audio, sample_rate) - logger.debug("Cached audio: %s (total_cache_size=%.2fMB)", path.name, self._cache_total_bytes / (1024*1024)) - return np.array(audio, dtype=np.float32, copy=True), sample_rate - - except Exception as exc: - logger.debug("soundfile fallo para %s: %s", path.name, exc) - - if librosa is None: - raise RuntimeError(f"No se pudo leer audio (sin soundfile ni librosa): {path.name}") - - logger.debug("Falling back to librosa for: %s", path.name) - try: - audio, sample_rate = librosa.load(str(path), sr=self.sample_rate, mono=True) - audio = np.asarray(audio, dtype=np.float32).reshape(-1, 1) - audio = np.repeat(audio, 2, axis=1) - - # Validacion defensiva - verificar que no este vacio - if audio.size == 0: - logger.warning("AUDIO_LOAD: fallback to silence (empty audio from %s)", path.name) - silence = np.zeros((int(self.sample_rate), 2), dtype=np.float32) - return silence, self.sample_rate - - duration_s = len(audio) / self.sample_rate - logger.debug("Loaded via librosa: %s (sample_rate=%d, duration=%.2fs, channels=2)", - path.name, self.sample_rate, duration_s) - - # Guardar en cache - self._cache_put(cache_key, audio, self.sample_rate) - logger.debug("Cached audio: %s", cache_key) - return np.array(audio, dtype=np.float32, copy=True), self.sample_rate - - except Exception as exc: - logger.error("No se pudo leer audio con librosa: %s: %s", path.name, exc) - raise RuntimeError(f"No se pudo leer audio con librosa: {path.name}: {exc}") - - def _write_audio(self, file_path: Path, audio: np.ndarray, sample_rate: int) -> str: - """Escribe audio a archivo WAV. - - Args: - file_path: Ruta de destino - audio: Array de audio - sample_rate: Sample rate - - Returns: - Ruta del archivo escrito como string - - Raises: - RuntimeError: Si soundfile no esta disponible o el audio es invalido - """ - if sf is None: - raise RuntimeError("soundfile no disponible para escribir audio") - - # Validacion defensiva - audio = self._validate_audio_array(audio, context="_write_audio") - sample_rate = self._validate_positive(sample_rate, "sample_rate") - - if audio.ndim == 1: - audio = audio.reshape(-1, 1) - if audio.shape[1] == 1: - audio = np.repeat(audio, 2, axis=1) - sf.write(str(file_path), audio, int(sample_rate)) - return str(file_path) - - def _resample_audio(self, audio: np.ndarray, source_sr: int, target_sr: int) -> np.ndarray: - """Cambia el sample rate de audio. - - Args: - audio: Array de audio - source_sr: Sample rate origen - target_sr: Sample rate destino - - Returns: - Audio resampleado - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_resample_audio") - source_sr = max(1, int(source_sr)) - target_sr = max(1, int(target_sr)) - - if source_sr == target_sr: - return np.array(audio, dtype=np.float32) - - factor = float(target_sr) / float(source_sr) - target_len = max(1, int(round(audio.shape[0] * factor))) - return self._stretch_to_length(audio, target_len) - - def _stretch_to_length(self, audio: np.ndarray, target_len: int) -> np.ndarray: - """Estira o comprime audio a una longitud especifica. - - Usa scipy.signal.resample_poly si esta disponible (mejor calidad con anti-aliasing), - sino scipy.signal.resample (FFT-based), sino librosa.resample, sino np.interp como fallback. - - Args: - audio: Array de audio (samples, channels) - target_len: Longitud objetivo en samples - - Returns: - Audio estirado/comprimido - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_stretch_to_length") - target_len = max(1, int(target_len)) - - # Validacion adicional: si el audio esta vacio o target_len es 0, retornar silencio - if audio.size == 0 or target_len == 0: - logger.warning("_stretch_to_length: audio vacio o target_len=0, retornando silencio de longitud %d", target_len) - return np.zeros((target_len, 2), dtype=np.float32) - - if audio.shape[0] == target_len: - return np.array(audio, dtype=np.float32) - - # Caso edge: array de 1 sample - if audio.shape[0] <= 1: - return np.repeat(np.asarray(audio, dtype=np.float32), target_len, axis=0) - - original_len = audio.shape[0] - - def _fit_channel_length(channel_audio: np.ndarray) -> np.ndarray: - fitted = np.asarray(channel_audio, dtype=np.float32).reshape(-1) - current_len = fitted.shape[0] - if current_len == target_len: - return fitted - if current_len > target_len: - return fitted[:target_len] - if current_len <= 0: - return np.zeros(target_len, dtype=np.float32) - pad_value = float(fitted[-1]) - padding = np.full(target_len - current_len, pad_value, dtype=np.float32) - return np.concatenate([fitted, padding], axis=0) - - # Intentar usar scipy.signal.resample_poly (mejor calidad con anti-aliasing) - if scipy_signal is not None: - try: - from fractions import Fraction - # Calcular ratio como fraccion simplificada - ratio = Fraction(target_len, original_len).limit_denominator(1000) - up = ratio.numerator - down = ratio.denominator - - stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) - for channel in range(audio.shape[1]): - # resample_poly usa filtros anti-aliasing para mejor calidad - resampled = scipy_signal.resample_poly(audio[:, channel], up, down) - stretched[:, channel] = _fit_channel_length(resampled) - return stretched - except Exception as exc: - logger.debug("scipy.signal.resample_poly fallo: %s, intentando resample normal", exc) - # Fallback a resample normal dentro del mismo bloque - try: - stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) - for channel in range(audio.shape[1]): - # resample usa FFT para mejor calidad que interpolacion lineal - stretched[:, channel] = scipy_signal.resample( - audio[:, channel], target_len - ).astype(np.float32) - return stretched - except Exception as exc2: - logger.debug("scipy.signal.resample fallo: %s, usando fallback", exc2) - - # Intentar usar librosa.resample (buena calidad) - if librosa is not None: - try: - # librosa.resample requiere sample rates originales y destino - # Usamos valores ficticios que producen el ratio correcto - orig_sr = original_len - target_sr = target_len - - stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) - for channel in range(audio.shape[1]): - resampled = librosa.resample( - audio[:, channel], - orig_sr=orig_sr, - target_sr=target_sr, - res_type="linear" # Mas rapido, pero mejor que np.interp puro - ) - stretched[:, channel] = _fit_channel_length(resampled) - return stretched - except Exception as exc: - logger.debug("librosa.resample fallo: %s, usando np.interp", exc) - - # Fallback: np.interp (interpolacion lineal - menor calidad) - source_x = np.linspace(0.0, 1.0, original_len, endpoint=True) - target_x = np.linspace(0.0, 1.0, target_len, endpoint=True) - stretched = np.zeros((target_len, audio.shape[1]), dtype=np.float32) - for channel in range(audio.shape[1]): - stretched[:, channel] = np.interp(target_x, source_x, audio[:, channel]).astype(np.float32) - return stretched - - def _normalize(self, audio: np.ndarray, peak: float = None, soft_limit: bool = True) -> np.ndarray: - """Normaliza el pico del audio con soft limiting mejorado. - - Phase 1 Improvements: - - Soft knee con curva cubica suave (mas natural que lineal) - - Mejor preservacion de dinamica en el rango normal - - Args: - audio: Array de audio - peak: Nivel de pico objetivo (0.01 - 1.0). Por defecto usa _DEFAULT_PEAK (0.85). - soft_limit: Si True, aplica soft knee con curva cubica. - - Returns: - Audio normalizado - """ - # Usar valor por defecto unificado si no se especifica - if peak is None: - peak = self._DEFAULT_PEAK - - # Validacion defensiva - if audio is None or audio.size == 0: - return audio - - audio = np.asarray(audio, dtype=np.float32, copy=True) - peak = max(0.01, min(1.0, float(peak))) - - current_peak = float(np.max(np.abs(audio))) if audio.size else 0.0 - if current_peak <= 1e-6: - return audio - - # Aplicar soft limiting mejorado si esta habilitado - if soft_limit: - # Soft knee con curva cubica: mas suave que lineal, menos agresivo que tanh - # La curva cubica preserva mas dinamica en el rango normal - knee_start = peak * 0.75 # Knee empieza al 75% del peak - - abs_audio = np.abs(audio) - mask = abs_audio > knee_start - - if np.any(mask): - sign = np.sign(audio) - # Calcular posicion relativa dentro del knee (0 a 1) - knee_range = peak - knee_start - over_knee = abs_audio[mask] - knee_start - relative_pos = np.clip(over_knee / knee_range, 0.0, 1.0) - - # Curva cubica: (1 - (1-x)^3) para compresion suave - # Esto da una curva que empieza gradual y se aplane hacia el peak - compression_factor = 1.0 - np.power(1.0 - relative_pos, 3.0) - - # Aplicar compresion manteniendo la senal por debajo del peak - compressed = knee_start + knee_range * compression_factor - audio[mask] = sign[mask] * compressed - - # Recalcular peak despues del soft limiting - current_peak = float(np.max(np.abs(audio))) if audio.size else 0.0 - if current_peak <= 1e-6: - return audio - - # Normalizar al peak objetivo - return (audio / current_peak) * peak - - def _apply_fade( - self, - audio: np.ndarray, - fade_in_s: float = 0.02, - fade_out_s: float = 0.04, - fade_curve: str = "linear" - ) -> np.ndarray: - """Aplica fade in y fade out al audio. - - Args: - audio: Array de audio - fade_in_s: Duracion del fade in en segundos - fade_out_s: Duracion del fade out en segundos - fade_curve: Tipo de curva ("linear", "logarithmic", "exponential") - - Returns: - Audio con fades aplicados - """ - # Validacion defensiva - if audio is None or audio.size == 0: - return np.zeros((1, 2), dtype=np.float32) - - output = np.array(audio, dtype=np.float32, copy=True) - - # Asegurar 2D - if output.ndim == 1: - output = output.reshape(-1, 1) - - total = output.shape[0] - if total <= 2: - return output - - # Validar y clamp tiempos de fade - fade_in_s = max(0.0, float(fade_in_s)) - fade_out_s = max(0.0, float(fade_out_s)) - - fade_in = min(total, max(0, int(round(fade_in_s * self.sample_rate)))) - fade_out = min(total, max(0, int(round(fade_out_s * self.sample_rate)))) - - # Funcion auxiliar para generar curvas de fade - def _generate_fade_curve(length: int, direction: str) -> np.ndarray: - """Genera curva de fade segun el tipo especificado.""" - if fade_curve == "logarithmic": - # Curva logaritmica: inicio suave, transicion gradual - # Usa curva tipo -cos(0 a pi/2) o equivalente: 1 - e^(-3x) normalizado - x = np.linspace(0.0, 1.0, length, dtype=np.float32) - # Logarithmic-like curve: 1 - exp(-k*x) normalizado - k = 4.0 # Factor de curvatura - curve = (1.0 - np.exp(-k * x)) / (1.0 - np.exp(-k)) - elif fade_curve == "exponential": - # Curva exponencial: inicio rapido, final gradual - x = np.linspace(0.0, 1.0, length, dtype=np.float32) - curve = np.power(x, 2.0) # x^2 para curva exponencial simple - else: - # Linear por defecto - curve = np.linspace(0.0, 1.0, length, dtype=np.float32) - - if direction == "out": - curve = curve[::-1] - return curve.reshape(-1, 1) - - if fade_in > 0: - fade_in_curve = _generate_fade_curve(fade_in, "in") - output[:fade_in] *= fade_in_curve - if fade_out > 0: - fade_out_curve = _generate_fade_curve(fade_out, "out") - output[-fade_out:] *= fade_out_curve - return output - - def _apply_short_crossfade(self, audio: np.ndarray, fade_samples: int = 220, equal_power: bool = True) -> np.ndarray: - """Aplica un crossfade corto (5ms por defecto) en ambos extremos del audio. - - Phase 1 Improvements: - - Crossfades equal-power (sin/cos) para mejor calidad y menos artefactos - - Los crossfades equal-power mantienen la energia constante durante la transicion - - Esto elimina clicks al concatenar segmentos de audio extraidos. - - Args: - audio: Array de audio (samples, channels) - fade_samples: Numero de samples para el fade (220 = ~5ms a 44100Hz) - equal_power: Si True, usa curvas equal-power (sin/cos), sino lineales - - Returns: - Audio con crossfades aplicados - """ - # Validacion defensiva - if audio is None or audio.size == 0: - return np.zeros((1, 2), dtype=np.float32) - - output = np.array(audio, dtype=np.float32, copy=True) - - # Asegurar 2D - if output.ndim == 1: - output = output.reshape(-1, 1) - - total = output.shape[0] - if total <= 4: - return output - - # Clamp fade_samples a rango valido - fade_samples = max(1, min(fade_samples, total // 2)) - - if equal_power: - # Equal-power crossfade: mantiene energia constante - # fade_in = sin(x * pi/2), fade_out = cos(x * pi/2) - x = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32) - fade_in_curve = np.sin(x * np.pi / 2.0).reshape(-1, 1) - fade_out_curve = np.cos(x * np.pi / 2.0).reshape(-1, 1) - else: - # Fallback a curvas lineales - fade_in_curve = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32).reshape(-1, 1) - fade_out_curve = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32).reshape(-1, 1) - - output[:fade_samples] *= fade_in_curve - output[-fade_samples:] *= fade_out_curve - - return output - - def _extract_tail(self, audio: np.ndarray, seconds: float, min_length: float = 0.1) -> np.ndarray: - """Extrae los ultimos N segundos de audio con crossfade corto para eliminar clicks. - - Args: - audio: Array de audio - seconds: Duracion a extraer en segundos - min_length: Longitud minima en segundos (default: 0.1s = 4410 samples) - - Returns: - Segmento de audio extraido con crossfade aplicado - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_extract_tail") - seconds = max(0.001, float(seconds)) # Al menos 1ms - min_length = max(0.001, float(min_length)) # Al menos 1ms - - samples = max(1, int(round(seconds * self.sample_rate))) - min_samples = max(1, int(round(min_length * self.sample_rate))) - - # Si el audio es muy corto, retornar todo el audio - if audio.shape[0] <= samples: - segment = np.array(audio, dtype=np.float32, copy=True) - # Aplicar crossfade incluso si es todo el audio - return self._apply_short_crossfade(segment, fade_samples=220) - - segment = np.array(audio[-samples:], dtype=np.float32, copy=True) - - # Validar que el segmento no sea muy corto - if segment.shape[0] < min_samples: - logger.warning("_extract_tail: segmento muy corto (%d samples), usando todo el audio disponible", segment.shape[0]) - segment = np.array(audio, dtype=np.float32, copy=True) - - # Aplicar crossfade corto (5ms) para eliminar clicks en el corte - segment = self._apply_short_crossfade(segment, fade_samples=220) - - return segment - - def _extract_center(self, audio: np.ndarray, seconds: float) -> np.ndarray: - """Extrae el centro del audio con crossfades cortos para eliminar clicks. - - Args: - audio: Array de audio - seconds: Duracion a extraer en segundos - - Returns: - Segmento de audio extraido con crossfades aplicados - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_extract_center") - seconds = max(0.001, float(seconds)) # Al menos 1ms - - samples = max(1, int(round(seconds * self.sample_rate))) - if audio.shape[0] <= samples: - segment = np.array(audio, dtype=np.float32, copy=True) - # Aplicar crossfade incluso si es todo el audio - return self._apply_short_crossfade(segment, fade_samples=220) - - start = max(0, (audio.shape[0] - samples) // 2) - segment = np.array(audio[start:start + samples], dtype=np.float32, copy=True) - - # Aplicar crossfade corto (5ms) en ambos extremos para eliminar clicks - segment = self._apply_short_crossfade(segment, fade_samples=220) - - return segment - - def _find_hot_slice(self, audio: np.ndarray, seconds: float, min_samples: int = -1) -> np.ndarray: - """Encuentra el segmento con mayor energia con crossfades cortos para eliminar clicks. - - Args: - audio: Array de audio - seconds: Duracion del segmento en segundos - min_samples: Longitud minima del resultado en samples (default: 1000) - - Returns: - Segmento de mayor energia con crossfades aplicados - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_find_hot_slice") - seconds = max(0.001, float(seconds)) # Al menos 1ms - # Usar constante minima de efecto si no se especifica - if min_samples < 0: - min_samples = self._MIN_SAMPLES_FOR_EFFECT - else: - min_samples = max(self._MIN_SAMPLES_FOR_EFFECT, int(min_samples)) - - samples = max(min_samples, int(round(seconds * self.sample_rate))) - if audio.shape[0] <= samples: - # Si el audio es muy corto, paddear a min_samples - if audio.shape[0] < min_samples: - logger.debug("HOT_SLICE: padded short audio from %d to %d samples", audio.shape[0], min_samples) - padding = np.zeros((min_samples - audio.shape[0], audio.shape[1]), dtype=np.float32) - audio = np.concatenate([audio, padding], axis=0) - segment = np.array(audio, dtype=np.float32, copy=True) - # Aplicar crossfade incluso si es todo el audio - return self._apply_short_crossfade(segment, fade_samples=220) - - mono = np.mean(np.abs(audio), axis=1) - window = max(8, samples) - energy = np.convolve(mono, np.ones(window, dtype=np.float32), mode="valid") - - # Handle edge case: energia vacia - if energy.size == 0: - segment = np.array(audio[:samples], dtype=np.float32, copy=True) - # Validar longitud minima - if segment.shape[0] < min_samples: - logger.debug("HOT_SLICE: padded short audio from %d to %d samples (empty energy)", segment.shape[0], min_samples) - padding = np.zeros((min_samples - segment.shape[0], segment.shape[1]), dtype=np.float32) - segment = np.concatenate([segment, padding], axis=0) - return self._apply_short_crossfade(segment, fade_samples=220) - - start = int(np.argmax(energy)) - segment = np.array(audio[start:start + samples], dtype=np.float32, copy=True) - - # Validar longitud minima del resultado - if segment.shape[0] < min_samples: - logger.debug("HOT_SLICE: padded short audio from %d to %d samples (result)", segment.shape[0], min_samples) - padding = np.zeros((min_samples - segment.shape[0], segment.shape[1]), dtype=np.float32) - segment = np.concatenate([segment, padding], axis=0) - - # Aplicar crossfade corto (5ms) en ambos extremos para eliminar clicks - segment = self._apply_short_crossfade(segment, fade_samples=220) - - return segment - - def _apply_short_reverb(self, audio: np.ndarray, decay: float = 0.3, delay_ms: float = 50.0) -> np.ndarray: - """Aplica un reverb corto mediante delays con feedback. - - Simula una respuesta impulsional corta (~100ms) para dar profundidad - al audio invertido sin crear una cola larga. - - Args: - audio: Array de audio (samples, channels) - decay: Factor de decaimiento del reverb (0.0 - 0.8) - delay_ms: Delay base en milisegundos - - Returns: - Audio con reverb aplicado - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_apply_short_reverb") - decay = max(0.0, min(0.8, float(decay))) - delay_ms = max(5.0, min(200.0, float(delay_ms))) - - output = np.array(audio, dtype=np.float32, copy=True) - total_samples = output.shape[0] - - # Calcular samples de delay base - delay_samples = int(round(delay_ms * self.sample_rate / 1000.0)) - if delay_samples < 1 or total_samples < delay_samples + 1: - return output - - # Crear multiples taps de delay para simular reverb - # Taps con diferentes tiempos y ganancias - taps = [ - (1, 1.0, decay * 0.6), # 1er eco temprano - (int(delay_samples * 1.3), 0.9, decay * 0.4), # 2do eco - (int(delay_samples * 1.7), 0.85, decay * 0.3), # 3er eco - (int(delay_samples * 2.2), 0.8, decay * 0.2), # 4to eco (difuso) - ] - - for delay, gain, feedback in taps: - if delay >= total_samples: - continue - # Aplicar delay con feedback - delayed = np.zeros_like(output) - delayed[delay:] = output[:-delay] * gain * feedback - output = output + delayed - - # Mezclar wet/dry (30% wet) - wet = output * 0.3 - dry = audio * 0.7 - result = dry + wet - - # Normalizar para evitar clipping - max_val = np.max(np.abs(result)) - if max_val > 0.95: - result = result * (0.95 / max_val) - - return result.astype(np.float32) - - def _apply_delay_feedback( - self, - audio: np.ndarray, - delay_ms: float = 150.0, - feedback: float = 0.35, - mix: float = 0.25, - num_taps: int = 3 - ) -> np.ndarray: - """Aplica delay con feedback sutil para anadir profundidad y textura. - - Crea repeticiones que decaen gradualmente, ideal para reverse FX. - - Args: - audio: Array de audio (samples, channels) - delay_ms: Tiempo entre repeticiones en milisegundos (default: 150ms) - feedback: Factor de decaimiento por repeticion (0.0 - 0.7, default: 0.35) - mix: Nivel de la senal wet (0.0 - 0.5, default: 0.25) - num_taps: Numero de repeticiones (1-5, default: 3) - - Returns: - Audio con delay aplicado - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_apply_delay_feedback") - delay_ms = max(10.0, min(500.0, float(delay_ms))) - feedback = max(0.0, min(0.7, float(feedback))) - mix = max(0.0, min(0.5, float(mix))) - num_taps = max(1, min(5, int(num_taps))) - - output = np.zeros_like(audio, dtype=np.float32) - total_samples = audio.shape[0] - delay_samples = int(round(delay_ms * self.sample_rate / 1000.0)) - - # Validar que hay suficiente espacio para el delay - if delay_samples < 1 or total_samples < delay_samples + 1: - return np.array(audio, dtype=np.float32) - - # Copiar la senal dry - output = np.array(audio, dtype=np.float32, copy=True) - - # Anadir taps de delay con feedback decreciente - current_gain = feedback - for tap in range(1, num_taps + 1): - tap_delay = delay_samples * tap - if tap_delay >= total_samples: - break - - # Crear senal delayada con gain decreciente - delayed = np.zeros_like(audio) - delayed[tap_delay:] = audio[:-tap_delay] * current_gain - - # Mezclar con output - output = output + delayed - - # Reducir gain para siguiente tap - current_gain *= feedback - - # Mezclar wet/dry - dry = audio * (1.0 - mix) - wet = output * mix - result = dry + wet - - # Normalizar para evitar clipping - max_val = np.max(np.abs(result)) - if max_val > 0.95: - result = result * (0.95 / max_val) - - return result.astype(np.float32) - - def _apply_hpf(self, audio: np.ndarray, cutoff_hz: float = 100.0) -> np.ndarray: - """Aplica un filtro high-pass para limpiar frecuencias bajas (mud). - - Usa scipy.signal.butter si esta disponible, sino una aproximacion - por diferenciacion de primer orden. - - Args: - audio: Array de audio (samples, channels) - cutoff_hz: Frecuencia de corte en Hz (tipica: 80-120 Hz) - - Returns: - Audio filtrado - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_apply_hpf") - cutoff_hz = max(20.0, min(500.0, float(cutoff_hz))) - - output = np.zeros_like(audio, dtype=np.float32) - num_channels = audio.shape[1] - total_samples = audio.shape[0] - - # Intentar usar scipy para mejor calidad - if scipy_signal is not None: - try: - # Filtro Butterworth high-pass de 2do orden - nyquist = self.sample_rate / 2.0 - normalized_cutoff = min(0.49, cutoff_hz / nyquist) # Evitar Nyquist - b, a = scipy_signal.butter(2, normalized_cutoff, btype='high', analog=False) - for ch in range(num_channels): - output[:, ch] = scipy_signal.filtfilt(b, a, audio[:, ch]).astype(np.float32) - return output - except Exception as exc: - logger.debug("scipy HPF fallo: %s, usando fallback por diferenciacion", exc) - - # Fallback: filtro high-pass por diferenciacion (RC) - rc = 1.0 / (2.0 * 3.14159265359 * cutoff_hz) - dt = 1.0 / self.sample_rate - alpha = rc / (rc + dt) - - for ch in range(num_channels): - prev_input = 0.0 - prev_output = 0.0 - for i in range(total_samples): - current_input = float(audio[i, ch]) - output[i, ch] = alpha * (prev_output + current_input - prev_input) - prev_input = current_input - prev_output = float(output[i, ch]) - - return output.astype(np.float32) - - def _apply_hpf_sweep(self, audio: np.ndarray, start_hz: float = 200.0, end_hz: float = 2000.0) -> np.ndarray: - """Aplica un HPF sweep que va desde start_hz hasta end_hz. - - Phase 1 Improvements: - - Filtro Butterworth de 4to orden para pendientes mas pronunciadas (24dB/oct) - - Overlap-add mejorado con 75% overlap para transiciones mas suaves - - Normalizacion de ventana para evitar artefactos de amplitud - - El filtro high-pass barre su frecuencia de corte a lo largo del audio, - creando el clasico efecto de "sweep" usado en risers. - - Args: - audio: Array de audio (samples, channels) - start_hz: Frecuencia inicial del HPF (default 200Hz) - end_hz: Frecuencia final del HPF (default 2000Hz) - - Returns: - Audio con HPF sweep aplicado - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_apply_hpf_sweep") - start_hz = max(20.0, min(float(start_hz), self.sample_rate / 2.0 - 100)) - end_hz = max(start_hz, min(float(end_hz), self.sample_rate / 2.0 - 100)) - - # Sin scipy, devolver audio sin cambios - if scipy_signal is None: - logger.debug("scipy_signal no disponible, saltando HPF sweep") - return np.array(audio, dtype=np.float32) - - total_samples = audio.shape[0] - output = np.zeros_like(audio, dtype=np.float32) - - # Procesar en frames con overlap para evitar glitches - # Frames mas pequenos (25ms) con 75% overlap para transiciones mas suaves - frame_size = int(0.025 * self.sample_rate) # 25ms frames - hop_size = frame_size // 4 # 75% overlap - num_frames = max(1, (total_samples - frame_size) // hop_size + 1) - - # Ventana de Hann para overlap-add - window = np.hanning(frame_size).astype(np.float32) - - # Buffer para normalizacion de overlap - window_sum = np.zeros(total_samples, dtype=np.float32) - - for i in range(num_frames): - start_sample = i * hop_size - end_sample = min(start_sample + frame_size, total_samples) - - # Frecuencia de corte para este frame (interpolacion exponencial) - progress = i / max(1, num_frames - 1) - cutoff_hz = start_hz * (end_hz / start_hz) ** progress - - # Extraer frame - frame = audio[start_sample:end_sample] - actual_frame_size = frame.shape[0] - - if actual_frame_size < frame_size: - # Padding si es el ultimo frame - padded = np.zeros((frame_size, audio.shape[1]), dtype=np.float32) - padded[:actual_frame_size] = frame - frame = padded - actual_window = window.copy() - actual_window[actual_frame_size:] = 0.0 - else: - actual_window = window - - # Aplicar HPF Butterworth de 4to orden (24dB/octava) - try: - nyquist = self.sample_rate / 2.0 - normalized_cutoff = min(0.49, cutoff_hz / nyquist) - - # Filtro de 4to orden para pendiente mas pronunciada - b, a = scipy_signal.butter(4, normalized_cutoff, btype="high", output="ba") - - # Aplicar filtro a cada canal con filtfilt para fase cero - filtered = np.zeros_like(frame) - for ch in range(frame.shape[1]): - filtered[:, ch] = scipy_signal.filtfilt(b, a, frame[:, ch]) - - # Aplicar ventana - windowed = filtered * actual_window.reshape(-1, 1) - - # Acumular en output (overlap-add) - out_len = min(actual_frame_size, total_samples - start_sample) - output[start_sample:start_sample + out_len] += windowed[:out_len] - window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 - - except Exception as exc: - logger.debug("Error en HPF sweep frame %d: %s", i, exc) - # Fallback: copiar frame con ventana - windowed = frame * actual_window.reshape(-1, 1) - out_len = min(actual_frame_size, total_samples - start_sample) - output[start_sample:start_sample + out_len] += windowed[:out_len] - window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 - - # Normalizar por la suma de ventanas para compensar overlap - window_sum = np.maximum(window_sum, 1e-8) - output = output / window_sum.reshape(-1, 1) - - return output.astype(np.float32) - - def _apply_saturator(self, audio: np.ndarray, drive: float = 0.3) -> np.ndarray: - """Aplica saturacion suave usando tanh. - - La saturacion tanh simula el comportamiento de equipos analogicos, - anadiendo harmonicos de forma musical y suavizando los picos. - - Args: - audio: Array de audio (samples, channels) - drive: Cantidad de saturacion (0.0 - 1.0, default 0.3) - - Returns: - Audio saturado - """ - # Validaciones defensivas - audio = self._validate_audio_array(audio, context="_apply_saturator") - drive = max(0.0, min(1.0, float(drive))) - - if drive <= 0.001: - return np.array(audio, dtype=np.float32) - - # Saturacion suave usando tanh - gain = 1.0 + drive - saturated = np.tanh(audio * gain) / gain - - return saturated.astype(np.float32) - - def _render_reverse_fx(self, source_path: str, duration_s: float = 4.0, project_bpm: float = 120.0) -> np.ndarray: - """Renderiza efecto de reverse profesional mejorado. - - Incluye: - - Reverb profundo antes del reverse - - HPF agresivo para limpiar mud - - Swell exponencial dramatico - - Delay feedback sutil - - Fade-in con curva logaritmica natural - - Integracion con BPM del proyecto - - Args: - source_path: Ruta al archivo fuente - duration_s: Duracion en segundos - project_bpm: BPM del proyecto para sincronizacion (default: 120.0) - - Returns: - Audio procesado con reverse FX profesional - """ - # Validaciones defensivas - duration_s = max(0.1, float(duration_s)) - project_bpm = max(60.0, min(200.0, float(project_bpm or 120.0))) - logger.debug( - "Rendering REVERSE FX: source=%s, duration=%.1fs, bpm=%.0f", - Path(source_path).name, duration_s, project_bpm - ) - - # Largar y preparar segmento - audio, _ = self._load_audio(source_path) - # Usar constante minima para efecto - min_tail_duration = self._MIN_SAMPLES_FOR_EFFECT / self.sample_rate - tail_duration = max(min_tail_duration, duration_s * 0.85) - if tail_duration == min_tail_duration: - logger.debug("Using minimum tail duration %.3fs for short audio in reverse", min_tail_duration) - segment = self._extract_tail(audio, tail_duration) - reversed_audio = np.flip(segment, axis=0) - reversed_audio = self._stretch_to_length(reversed_audio, int(round(duration_s * self.sample_rate))) - - # 1. Aplicar reverb PROFUNDO para dar cuerpo antes del reverse - # Decay mas alto (0.55) y delay mas largo (90ms) para profundidad - reversed_audio = self._apply_short_reverb(reversed_audio, decay=0.55, delay_ms=90.0) - - # 2. HPF AGRESIVO para limpiar mud en frecuencias bajas - # Subir de 100Hz a 180Hz para reverse mas limpio y brillante - reversed_audio = self._apply_hpf(reversed_audio, cutoff_hz=180.0) - - # 3. Aplicar SWELL EXPONENCIAL DRAMATICO - # Usar ramp exponencial de volumen para build-up dramatico - length = reversed_audio.shape[0] - # Curva exponencial: comienza muy bajo y crece dramaticamente - # El factor 5.0 da un rango de ~-14dB a 0dB - swell_ramp = np.exp(np.linspace(np.log(0.05), np.log(1.0), length, dtype=np.float32)).reshape(-1, 1) - reversed_audio = reversed_audio * swell_ramp - - # 4. Aplicar DELAY FEEDBACK SUTIL para textura y espacio - # Delay sincronizado con BPM (1/8 de nota = 60*1000/(bpm*2) ms) - delay_ms_sync = (60000.0 / project_bpm) / 2.0 # 1/8 de nota - reversed_audio = self._apply_delay_feedback( - reversed_audio, - delay_ms=delay_ms_sync, - feedback=0.3, - mix=0.2, - num_taps=2 - ) - - # 5. Fade-in con CURVA LOGARITMICA para transicion natural - # Fade-in mas largo (0.4s) con curva logaritmica - reversed_audio = self._apply_fade( - reversed_audio, - fade_in_s=0.4, - fade_out_s=0.05, - fade_curve="logarithmic" - ) - - result = self._normalize(reversed_audio) - - final_duration = len(result) / self.sample_rate - logger.debug("REVERSE_FX: generated %s (duration=%.1fs)", Path(source_path).name, final_duration) - return result - - def _render_riser(self, source_path: str, duration_s: float = 8.0, bpm: float = 128.0) -> np.ndarray: - """Renderiza efecto de riser profesional con HPF sweep, ramp exponencial con plateau, y saturacion mejorada. - - Phase 1 Improvements: - - BPM-synced for better musical timing - - Longer plateau before the peak for sustain - - Enhanced HPF sweep curve (80Hz -> 3500Hz for more dramatic sweep) - - Added mid-frequency boost for presence - - Better saturation curve with progressive drive - - Longer sustain before final peak - - Args: - source_path: Ruta al archivo fuente - duration_s: Duracion en segundos - bpm: BPM del proyecto para sincronizacion (default: 128.0) - - Returns: - Audio procesado - """ - duration_s = max(0.1, float(duration_s)) - bpm = max(60.0, min(200.0, float(bpm or 128.0))) - logger.debug("Rendering RISER FX: source=%s, duration=%.1fs, bpm=%.0f", Path(source_path).name, duration_s, bpm) - - audio, _ = self._load_audio(source_path) - min_source_duration = self._MIN_SAMPLES_FOR_EFFECT / self.sample_rate - beat_duration = 60.0 / bpm - source_duration = max(min_source_duration, min(beat_duration * 4.0, duration_s / 3.5)) - if source_duration == min_source_duration: - logger.debug("Using minimum source duration %.3fs for short audio in riser", min_source_duration) - segment = self._extract_center(audio, source_duration) - - stages: List[np.ndarray] = [] - for speed in (1.0, 0.88, 0.75, 0.62): - target_len = max(self._MIN_SAMPLES_FOR_STRETCH, int(round(segment.shape[0] * speed))) - sped = self._stretch_to_length(segment, target_len) - stages.append(sped) - combined = np.concatenate(stages, axis=0) - combined = self._stretch_to_length(combined, int(round(duration_s * self.sample_rate))) - - num_samples = combined.shape[0] - logger.debug("RISER: Applying enhanced HPF sweep 80Hz -> 3500Hz") - combined = self._apply_hpf_sweep(combined, start_hz=80.0, end_hz=3500.0) - - t = np.linspace(0.0, 1.0, num_samples, dtype=np.float32) - plateau_start = 0.82 - plateau_end = 0.95 - - ramp = np.zeros(num_samples, dtype=np.float32) - ramp_phase = t[t <= plateau_start] - if len(ramp_phase) > 0: - ramp_indices = t <= plateau_start - exp_ramp = np.exp(np.linspace(np.log(0.03), np.log(0.92), ramp_indices.sum())) - ramp[ramp_indices] = exp_ramp - - plateau_mask = (t > plateau_start) & (t <= plateau_end) - if np.any(plateau_mask): - ramp[plateau_mask] = np.linspace(0.92, 0.98, plateau_mask.sum()) - - final_ramp_mask = t > plateau_end - if np.any(final_ramp_mask): - ramp[final_ramp_mask] = np.linspace(0.98, 1.0, final_ramp_mask.sum()) - - ramp = ramp.reshape(-1, 1) - combined = combined * ramp - - saturation_start = int(num_samples * 0.65) - tail = combined[saturation_start:].copy() - - logger.debug("RISER: Applying progressive saturation to tail (last 35%%)") - saturation_sections = [ - (0.0, 0.3, 0.15), - (0.3, 0.6, 0.25), - (0.6, 1.0, 0.35), - ] - - for start_ratio, end_ratio, drive in saturation_sections: - sect_start = int(tail.shape[0] * start_ratio) - sect_end = int(tail.shape[0] * end_ratio) - if sect_end > sect_start: - tail[sect_start:sect_end] = self._apply_saturator(tail[sect_start:sect_end], drive=drive) - - crossfade_len = min(int(0.015 * self.sample_rate), tail.shape[0]) - if crossfade_len > 0: - fade_curve = np.sin(np.linspace(0, np.pi/2, crossfade_len, dtype=np.float32)).reshape(-1, 1) - saturated_full = self._apply_saturator(tail, drive=0.28) - tail[:crossfade_len] = tail[:crossfade_len] * (1 - fade_curve) + saturated_full[:crossfade_len] * fade_curve - - combined[saturation_start:] = tail - - combined = self._apply_fade(combined, fade_in_s=0.08, fade_out_s=0.04) - result = self._normalize(combined, peak=0.85) - - final_duration = len(result) / self.sample_rate - logger.debug("RISER: generated %s (duration=%.1fs)", Path(source_path).name, final_duration) - return result - - def _apply_lpf_simple(self, audio: np.ndarray, cutoff_hz: float) -> np.ndarray: - """Aplica filtro low-pass simple (media movil exponencial). - - Args: - audio: Array de audio (samples, channels) - cutoff_hz: Frecuencia de corte en Hz - - Returns: - Audio filtrado - """ - audio = self._validate_audio_array(audio, context="_apply_lpf_simple") - cutoff_hz = max(20.0, min(20000.0, float(cutoff_hz))) - - # Constante de tiempo para el filtro RC - rc = 1.0 / (2.0 * 3.14159 * cutoff_hz) - dt = 1.0 / self.sample_rate - alpha = dt / (rc + dt) - - output = np.zeros_like(audio) - for ch in range(audio.shape[1]): - output[0, ch] = audio[0, ch] - for i in range(1, len(audio)): - output[i, ch] = output[i - 1, ch] + alpha * (audio[i, ch] - output[i - 1, ch]) - - return output.astype(np.float32) - - def _apply_lpf_sweep(self, audio: np.ndarray, start_hz: float = 8000.0, end_hz: float = 200.0) -> np.ndarray: - """Aplica barrido de filtro low-pass a lo largo del audio. - - Phase 1 Improvements: - - Filtro Butterworth de 4to orden para pendientes mas pronunciadas (24dB/oct) - - Overlap-add con 75% overlap para transiciones suaves - - Normalizacion de ventana para evitar artefactos de amplitud - - Fallback a filtro RC simple si scipy no disponible - - Args: - audio: Array de audio (samples, channels) - start_hz: Frecuencia inicial del sweep en Hz - end_hz: Frecuencia final del sweep en Hz - - Returns: - Audio con LPF sweep aplicado - """ - audio = self._validate_audio_array(audio, context="_apply_lpf_sweep") - start_hz = max(50.0, min(20000.0, float(start_hz))) - end_hz = max(20.0, min(20000.0, float(end_hz))) - - num_samples = audio.shape[0] - - # Si scipy disponible, usar Butterworth 4to orden con overlap-add - if scipy_signal is not None: - output = np.zeros_like(audio, dtype=np.float32) - - # Frames de 25ms con 75% overlap - frame_size = int(0.025 * self.sample_rate) - hop_size = frame_size // 4 # 75% overlap - num_frames = max(1, (num_samples - frame_size) // hop_size + 1) - - window = np.hanning(frame_size).astype(np.float32) - window_sum = np.zeros(num_samples, dtype=np.float32) - - for i in range(num_frames): - start_sample = i * hop_size - end_sample = min(start_sample + frame_size, num_samples) - - # Interpolacion exponencial de la frecuencia (mas musical) - progress = start_sample / num_samples - exp_progress = (np.exp(progress * 2.0) - 1.0) / (np.e ** 2.0 - 1.0) - cutoff = start_hz * (end_hz / start_hz) ** exp_progress - - frame = audio[start_sample:end_sample] - actual_frame_size = frame.shape[0] - - if actual_frame_size < frame_size: - padded = np.zeros((frame_size, audio.shape[1]), dtype=np.float32) - padded[:actual_frame_size] = frame - frame = padded - actual_window = window.copy() - actual_window[actual_frame_size:] = 0.0 - else: - actual_window = window - - try: - nyquist = self.sample_rate / 2.0 - normalized_cutoff = min(0.49, max(0.01, cutoff / nyquist)) - - # Butterworth 4to orden - b, a = scipy_signal.butter(4, normalized_cutoff, btype="low", output="ba") - - filtered = np.zeros_like(frame) - for ch in range(frame.shape[1]): - filtered[:, ch] = scipy_signal.filtfilt(b, a, frame[:, ch]) - - windowed = filtered * actual_window.reshape(-1, 1) - out_len = min(actual_frame_size, num_samples - start_sample) - output[start_sample:start_sample + out_len] += windowed[:out_len] - window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 - - except Exception as exc: - logger.debug("Error en LPF sweep frame %d: %s", i, exc) - windowed = frame * actual_window.reshape(-1, 1) - out_len = min(actual_frame_size, num_samples - start_sample) - output[start_sample:start_sample + out_len] += windowed[:out_len] - window_sum[start_sample:start_sample + out_len] += actual_window[:out_len] ** 2 - - # Normalizar por suma de ventanas - window_sum = np.maximum(window_sum, 1e-8) - output = output / window_sum.reshape(-1, 1) - return output.astype(np.float32) - - # Fallback: filtro RC simple por bloques - output = np.zeros_like(audio) - block_size = max(256, num_samples // 64) - num_blocks = (num_samples + block_size - 1) // block_size - - for block_idx in range(num_blocks): - start_sample = block_idx * block_size - end_sample = min(start_sample + block_size, num_samples) - - progress = start_sample / num_samples - exp_progress = (np.exp(progress * 2.0) - 1.0) / (np.e ** 2.0 - 1.0) - cutoff = start_hz * (end_hz / start_hz) ** exp_progress - - block_audio = audio[start_sample:end_sample] - filtered_block = self._apply_lpf_simple(block_audio, cutoff) - output[start_sample:end_sample] = filtered_block - - return output.astype(np.float32) - - def _apply_simple_reverb(self, audio: np.ndarray, decay: float = 0.3, wet_mix: float = 0.15, delay_ms: float = 50.0) -> np.ndarray: - """Aplica reverb simple con multiples delays. - - Args: - audio: Array de audio (samples, channels) - decay: Factor de decaimiento (0.0 - 0.9) - wet_mix: Mezcla de senal procesada (0.0 - 1.0) - delay_ms: Delay base en milisegundos - - Returns: - Audio con reverb aplicado - """ - audio = self._validate_audio_array(audio, context="_apply_simple_reverb") - decay = max(0.0, min(0.9, float(decay))) - wet_mix = max(0.0, min(1.0, float(wet_mix))) - delay_ms = max(1.0, min(200.0, float(delay_ms))) - - output = np.array(audio, dtype=np.float32, copy=True) - delay_samples = int(round(delay_ms * self.sample_rate / 1000.0)) - - # Multiples delays para crear reverb mas denso - delay_times = [1.0, 1.3, 1.7, 2.1] # Proporciones del delay base - decay_factors = [decay, decay * 0.7, decay * 0.5, decay * 0.3] - - for delay_ratio, decay_factor in zip(delay_times, decay_factors): - current_delay = int(round(delay_samples * delay_ratio)) - if current_delay < audio.shape[0]: - delayed = np.zeros_like(output) - delayed[current_delay:] = output[:-current_delay] * decay_factor - output = output + delayed - - # Mezclar dry y wet - dry_mix = 1.0 - wet_mix - return (audio * dry_mix + output * wet_mix).astype(np.float32) - - def _render_downlifter(self, source_path: str, duration_s: float = 6.0, bpm: float = 128.0) -> np.ndarray: - """Renderiza efecto de downlifter profesional con LPF sweep mejorado y reverb tail extendido. - - Phase 1 Improvements: - - BPM-synced for better musical timing - - Longer reverb tail with layered decay (up to 60% of duration) - - Enhanced LPF sweep curve (15000Hz -> 60Hz for more dramatic effect) - - Added subtle noise floor for depth - - Improved grain texture with BPM-synced rhythm - - Better volume envelope with Hz-tuned amplitude curve - - Args: - source_path: Ruta al archivo fuente - duration_s: Duracion en segundos - bpm: BPM del proyecto para sincronizar curvas - - Returns: - Audio procesado - """ - duration_s = max(0.1, float(duration_s)) - bpm = max(60.0, min(200.0, float(bpm or 128.0))) - logger.debug("Rendering DOWNLIFTER FX: source=%s, duration=%.1fs, bpm=%.1f", Path(source_path).name, duration_s, bpm) - - audio, _ = self._load_audio(source_path) - min_segment_duration = self._MIN_SAMPLES_FOR_EFFECT / self.sample_rate - beat_duration = 60.0 / bpm - segment_duration = max(min_segment_duration, min(beat_duration * 3.0, duration_s / 2.5)) - if segment_duration == min_segment_duration: - logger.debug("Using minimum segment duration %.3fs for short audio in downlifter", min_segment_duration) - segment = self._extract_tail(audio, segment_duration) - stretched = self._stretch_to_length(segment, int(round(duration_s * self.sample_rate))) - - num_samples = stretched.shape[0] - - t = np.linspace(0.0, 1.0, num_samples, dtype=np.float32) - - exp_decay = np.exp(-3.5 * t) - s_curve_start = 0.55 - s_mask = (t > s_curve_start).astype(np.float32) - s_t = (t - s_curve_start) / (1.0 - s_curve_start) - s_curve = 1.0 - (3.0 * s_t**2 - 2.0 * s_t**3) - - volume_curve = exp_decay * (1.0 - s_mask) + (exp_decay * s_curve) * s_mask - volume_curve = volume_curve * 0.97 + 0.03 - volume_curve = volume_curve.reshape(-1, 1) - stretched = stretched * volume_curve - - logger.debug("DOWNLIFTER: Applying enhanced LPF sweep 15000Hz -> 60Hz") - stretched = self._apply_lpf_sweep(stretched, start_hz=15000.0, end_hz=60.0) - - grain_rate_hz = bpm / 60.0 * 4.0 - grain_period = max(16, int(round(self.sample_rate / grain_rate_hz))) - grain_envelope = np.ones(num_samples, dtype=np.float32) - grain_depth = 0.025 - - grain_start = int(num_samples * 0.45) - for i in range(grain_start, num_samples, grain_period): - grain_samples = min(grain_period, num_samples - i) - if grain_samples <= 0: - continue - phase = np.linspace(0, np.pi * 2, min(grain_samples, grain_period), dtype=np.float32) - grain_wave = (np.sin(phase) * 0.5 + 0.5) * grain_depth - progress = (i - grain_start) / max(1, num_samples - grain_start) - grain_wave *= (1.0 + progress * 0.6) - end_idx = min(i + grain_samples, num_samples) - apply_len = min(len(grain_wave), end_idx - i) - if apply_len > 0: - grain_envelope[i:i + apply_len] = grain_envelope[i:i + apply_len] * (1.0 - grain_wave[:apply_len]) - - grain_envelope = grain_envelope.reshape(-1, 1) - stretched = stretched * grain_envelope - - tail_start = int(num_samples * 0.48) - tail = stretched[tail_start:].copy() - - tail_with_reverb = self._apply_simple_reverb( - tail, - decay=0.6, - wet_mix=0.4, - delay_ms=30.0 - ) - - tail_with_reverb = self._apply_simple_reverb( - tail_with_reverb, - decay=0.45, - wet_mix=0.18, - delay_ms=65.0 - ) - - if tail_with_reverb.shape[0] > 0: - layer_depth_start = int(tail_with_reverb.shape[0] * 0.6) - depth_layer = tail_with_reverb[layer_depth_start:].copy() - if depth_layer.shape[0] > 0: - depth_layer = self._apply_simple_reverb(depth_layer, decay=0.35, wet_mix=0.12, delay_ms=100.0) - tail_with_reverb[layer_depth_start:] = depth_layer - - stretched = np.concatenate([stretched[:tail_start], tail_with_reverb], axis=0) - - fade_duration_s = min(1.4, duration_s * 0.28) - fade_samples = int(round(fade_duration_s * self.sample_rate)) - - if fade_samples > 0 and fade_samples < stretched.shape[0]: - fade_start = stretched.shape[0] - fade_samples - fade_t = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32) - fade_curve = np.log1p(-fade_t * 0.95 + 0.05) / np.log(0.05) - fade_curve = np.clip(fade_curve, 0.0, 1.0) - fade_curve = fade_curve ** 0.65 - stretched[fade_start:] = stretched[fade_start:] * fade_curve.reshape(-1, 1) - - stretched = self._apply_fade(stretched, fade_in_s=0.02, fade_out_s=0.0) - result = self._normalize(stretched, peak=0.82) - - final_duration = len(result) / self.sample_rate - logger.debug("DOWNLIFTER: generated %s (duration=%.1fs)", Path(source_path).name, final_duration) - return result - - def _apply_slice_window(self, audio: np.ndarray, fade_samples: int = 44) -> np.ndarray: - """Aplica ventana con fade in/out muy corto a cada slice para evitar clicks. - - Args: - audio: Array de audio (samples, channels) - fade_samples: Numero de samples para el fade (default: 44 = ~1ms a 44.1kHz) - - Returns: - Audio con ventana aplicada - """ - if audio is None or audio.size == 0: - return audio - - audio = np.asarray(audio, dtype=np.float32) - if audio.ndim == 1: - audio = audio.reshape(-1, 1) - - total = audio.shape[0] - if total <= fade_samples * 2: - # Si el slice es muy corto, aplicar ventana completa tipo Hanning - window = np.hanning(total) - return audio * window.reshape(-1, 1) - - # Crear ventana: fade in al inicio, fade out al final - window = np.ones(total, dtype=np.float32) - window[:fade_samples] = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32) - window[-fade_samples:] = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32) - - return audio * window.reshape(-1, 1) - - def _render_stutter(self, source_path: str, duration_s: float = 2.5) -> np.ndarray: - """Renderiza efecto de stutter con sonido mas musical y organico. - - Mejoras implementadas: - - Numero de slices dinamico segun duracion (5-9 slices) - - Posiciones no uniformes con variacion aleatoria natural - - Pitch shift hasta 1 semitono hacia el final - - Reverb en los gaps entre slices para espacialidad - - Fade windows mas cortos (~0.5ms) - - Variacion de ganancia y timing para menos mecanicidad - - Args: - source_path: Ruta al archivo fuente - duration_s: Duracion en segundos - - Returns: - Audio procesado - """ - # Validaciones defensivas - duration_s = max(0.1, float(duration_s)) - logger.debug("Rendering STUTTER FX: source=%s, duration=%.1fs", Path(source_path).name, duration_s) - - audio, _ = self._load_audio(source_path) - source = self._find_hot_slice(audio, 0.20) # Ligeramente mas largo para mas contenido - output_len = int(round(duration_s * self.sample_rate)) - - # Asegurar que output_len sea valido - output_len = max(1, output_len) - - output = np.zeros((output_len, source.shape[1]), dtype=np.float32) - output = _ensure_2d_float(output) - - # Numero dinamico de slices segun duracion (mas cortos = menos slices) - # 5 slices para <2s, hasta 9 slices para >4s - num_slices = int(5 + min(4, int(duration_s / 1.0))) - num_slices = max(5, min(9, num_slices)) - - # Generar posiciones base con curva exponencial (mas denso hacia el final) - # Esto crea un patron mas musical tipo "building up" - base_positions = [] - for i in range(num_slices): - # Curva exponencial: 0 -> 0.85 con densidad creciente - t = i / max(1, num_slices - 1) - # Funcion exponencial para agrupar mas hacia el final - pos = (t ** 1.6) * 0.85 - base_positions.append(pos) - - # Aplicar variacion aleatoria a las posiciones para sonido mas organico - # Usar hash del source_path como semilla para consistencia - seed_hash = int(hashlib.md5(source_path.encode()).hexdigest()[:8], 16) % 10000 - np.random.seed(seed_hash) - - positions = [] - for i, base_pos in enumerate(base_positions): - # Variacion de +/- 3% en posicion - variation = (np.random.random() - 0.5) * 0.06 - pos = (base_pos + variation) * duration_s - # Asegurar que no se solapen demasiado - if i > 0: - pos = max(pos, positions[-1] + 0.08) - positions.append(min(pos, duration_s - 0.1)) - - logger.debug("STUTTER: placing %d slices at positions: %s", num_slices, [round(p, 3) for p in positions]) - - # Duracion base del slice con variacion - base_slice_duration = 0.16 - - # Crear buffer de reverb para los gaps (cola de reverb corta) - reverb_tail_samples = int(0.08 * self.sample_rate) # 80ms de reverb tail - - for index, position in enumerate(positions): - start = int(round(float(position) * self.sample_rate)) - - # Variar duracion del gate: mas corto hacia el final con variacion aleatoria - gate_variation = (np.random.random() - 0.5) * 0.04 # +/- 20ms - gate_duration = base_slice_duration - (index * 0.012) + gate_variation - # Usar constante minima para slice de stutter - min_gate_duration = self._MIN_SAMPLES_FOR_SLICE / self.sample_rate - gate_duration = max(min_gate_duration, gate_duration) - if gate_duration == min_gate_duration: - logger.debug("Using minimum slice duration %.3fs for short audio", min_gate_duration) - gate_len = max(self._MIN_SAMPLES_FOR_SLICE, min(source.shape[0], int(round(gate_duration * self.sample_rate)))) - - # Extraer slice con copia - slice_audio = np.array(source[:gate_len], dtype=np.float32, copy=True) - slice_audio = _ensure_2d_float(slice_audio) - - # VALIDACION TEMPRANA: Verificar que el slice tiene contenido real - # _ensure_2d_float retorna (1,1) con zeros si esta vacio, verificamos shape - if slice_audio.shape[0] <= 1: - logger.debug("STUTTER: slice %d has invalid shape after ensure_2d_float %s, skipping", index, slice_audio.shape) - continue - - # Pitch shift mas extremo hacia el final (hasta 1 semitono = 1.0595) - # Aplicar desde el slice 3 en adelante - if index >= 3: - # Calcular pitch factor: va de 1.02 hasta ~1.06 (1 semitono) - pitch_progress = (index - 3) / max(1, num_slices - 4) - # Factor de pitch: 1.02 hasta 1.06 (casi 1 semitono) - pitch_factor = 1.02 + (pitch_progress * 0.04) - # Anadir pequena variacion aleatoria al pitch (+/- 10 cents) - pitch_variation = 1.0 + (np.random.random() - 0.5) * 0.012 - pitch_factor *= pitch_variation - - if scipy_signal is not None: - try: - pitched_len = max(1, int(len(slice_audio) / pitch_factor)) - pitched = np.zeros((pitched_len, slice_audio.shape[1]), dtype=np.float32) - for ch in range(slice_audio.shape[1]): - pitched[:, ch] = scipy_signal.resample(slice_audio[:, ch], pitched_len).astype(np.float32) - slice_audio = pitched - logger.debug("STUTTER: slice %d pitch shifted by factor %.3f", index, pitch_factor) - except Exception: - pass # Mantener slice original si falla - - # VALIDACION: Verificar que pitch shift no produjo array vacio - if slice_audio.size == 0: - logger.debug("STUTTER: slice %d empty after pitch shift, skipping", index) - continue - - # Aplicar ventana con fade mas corto (~0.5ms = 22 samples a 44.1kHz) - fade_samples = 22 # Reducido de 44 para transiciones mas rapidas - slice_audio = self._apply_slice_window(slice_audio, fade_samples=fade_samples) - - # VALIDACION: Verificar que window no produjo array vacio - if slice_audio.size == 0: - logger.debug("STUTTER: slice %d empty after window, skipping", index) - continue - - # Aplicar pequeño reverb al slice para espacialidad - # Wet mix bajo para no perder definicion - slice_audio = self._apply_short_reverb(slice_audio, decay=0.25, delay_ms=35.0) - - # VALIDACION: Verificar que reverb no produjo array vacio - if slice_audio.size == 0: - logger.debug("STUTTER: slice %d empty after reverb, skipping", index) - continue - - end = min(output_len, start + slice_audio.shape[0]) - if end <= start: - logger.debug("STUTTER: slice %d has invalid range (start=%d, end=%d), skipping", index, start, end) - continue - - # Ajustar slice al espacio disponible - actual_len = end - start - - # VALIDACION CRITICA: Asegurar que actual_len sea al menos 1 - if actual_len <= 0: - logger.debug("STUTTER: slice %d has actual_len=%d, skipping", index, actual_len) - continue - - # Trim solo si hay suficiente contenido despues del trim - if actual_len < slice_audio.shape[0]: - # Asegurar que el trim no produzca array vacio - if actual_len >= 1: - slice_audio = slice_audio[:actual_len] - else: - logger.debug("STUTTER: slice %d would become empty after trim (actual_len=%d), skipping", index, actual_len) - continue - - # VALIDACION FINAL: Verificar que slice_audio tiene contenido antes de mezclar - if slice_audio.size == 0: - logger.debug("STUTTER: slice %d is empty before mix, skipping", index) - continue - - # Ganancia variable por posicion con variacion aleatoria - # Mas alto hacia el final con pequenas variaciones - gain_base = 0.50 + (index * 0.07) - gain_variation = (np.random.random() - 0.5) * 0.08 # +/- 4% - gain = gain_base + gain_variation - gain = max(0.3, min(0.95, gain)) # Clamp entre 0.3 y 0.95 - - # Validate shapes before mixing - valid, msg = _validate_mix_shapes(output[start:end], slice_audio) - if not valid: - logger.debug("STUTTER: skipping slice %d at %d: %s", index, start, msg) - continue - - output[start:end] += slice_audio * gain - - # Agregar reverb "ghost" en el gap despues del slice (solo si no es el ultimo) - if index < len(positions) - 1: - gap_start = end - gap_end = min(output_len, gap_start + reverb_tail_samples) - if gap_end > gap_start: - # Crear ghost reverb tail muy sutil del slice anterior - ghost_len = gap_end - gap_start - - # VALIDACION: Asegurar que ghost_len es valido - if ghost_len <= 0: - logger.debug("STUTTER: slice %d has invalid ghost_len=%d, skipping ghost", index, ghost_len) - else: - ghost_audio = np.zeros((ghost_len, source.shape[1]), dtype=np.float32) - - # Copiar la cola del slice con decaimiento exponencial - # VALIDACION: Asegurar que tail_source tiene contenido - tail_samples = min(len(slice_audio), ghost_len * 2) - if tail_samples > 0: - tail_source = slice_audio[-tail_samples:] - if tail_source.size > 0: - decay_len = min(len(tail_source), ghost_len) - # VALIDACION: Asegurar que decay_len es valido - if decay_len > 0: - decay_curve = np.exp(-4.0 * np.linspace(0, 1, decay_len)).reshape(-1, 1).astype(np.float32) - # VALIDACION: El slicing defensivo asegura que tail_source[-decay_len:] tiene contenido - if tail_source[-decay_len:].size > 0: - ghost_audio[:decay_len] = tail_source[-decay_len:] * decay_curve * 0.15 - output[gap_start:gap_start + ghost_len] += ghost_audio - else: - logger.debug("STUTTER: slice %d tail_source slice is empty, skipping ghost", index) - else: - logger.debug("STUTTER: slice %d has invalid decay_len=%d, skipping ghost", index, decay_len) - else: - logger.debug("STUTTER: slice %d tail_source is empty, skipping ghost", index) - else: - logger.debug("STUTTER: slice %d has invalid tail_samples=%d, skipping ghost", index, tail_samples) - - # Fade global mas suave - output = self._apply_fade(output, fade_in_s=0.003, fade_out_s=0.15) - result = self._normalize(output) # Usa valor unificado por defecto - - # Fallback for empty render results - if result is None or result.size == 0: - logger.warning("STUTTER: fallback to silence (empty render result)") - result = np.zeros((int(2.5 * self.sample_rate), 2), dtype=np.float32) - - final_duration = len(result) / self.sample_rate - logger.debug("STUTTER: generated %s (duration=%.1fs, slices=%d)", Path(source_path).name, final_duration, num_slices) - return result - - - def _output_path(self, source_path: str, variant_seed: int, suffix: str) -> Path: - """Genera ruta de salida unica para un archivo procesado.""" - source = Path(source_path) - digest = hashlib.sha1(f"{source.resolve()}::{variant_seed}::{suffix}".encode("utf-8")).hexdigest()[:10] - return self.output_dir / f"{source.stem}_{suffix}_{digest}.wav" - - def _analyze_source_quality(self, audio: np.ndarray, sample_rate: int, fx_type: str) -> Dict[str, Any]: - """Analyzes source audio quality for FX derivation. - - Returns quality metrics for source selection decisions. - - Args: - audio: Audio array (samples, channels) - sample_rate: Sample rate in Hz - fx_type: Type of FX to derive ('reverse', 'riser', 'downlifter', 'stutter') - - Returns: - Dict with quality metrics: spectral_content, dynamic_range, suitability_score - """ - if audio is None or audio.size == 0: - return {"spectral_content": 0.0, "dynamic_range": 0.0, "suitability_score": 0.0, "recommended": False} - - audio = self._validate_audio_array(audio, context="_analyze_source_quality") - - # Filtrar por duración (máx 45s) para evitar canciones completas - duration = audio.shape[0] / sample_rate - if duration > 45.0: - logger.debug(f"Source analysis: rejecting long audio ({duration:.1f}s > 45s)") - return {"spectral_content": 0.0, "dynamic_range": 0.0, "rms": 0.0, "suitability_score": 0.0, "recommended": False} - - mono = np.mean(np.abs(audio), axis=1) if audio.ndim > 1 else np.abs(audio) - - rms = float(np.sqrt(np.mean(mono ** 2))) if mono.size > 0 else 0.0 - peak = float(np.max(mono)) if mono.size > 0 else 0.0 - dynamic_range = peak / max(rms, 1e-10) - - spectral_content = 0.5 - if scipy_signal is not None and mono.size >= 512: - try: - freqs = np.fft.rfft(mono[:min(2048, len(mono))]) - freq_magnitude = np.abs(freqs) - if freq_magnitude.size > 10: - low_energy = np.sum(freq_magnitude[:max(1, len(freq_magnitude)//8)]) - mid_energy = np.sum(freq_magnitude[max(1, len(freq_magnitude)//8):len(freq_magnitude)//2]) - high_energy = np.sum(freq_magnitude[len(freq_magnitude)//2:]) - total = low_energy + mid_energy + high_energy + 1e-10 - high_ratio = high_energy / total - mid_ratio = mid_energy / total - spectral_content = float(0.3 + 0.5 * (high_ratio + mid_ratio * 0.5)) - except Exception: - pass - - suitability_scores = { - "reverse": min(1.0, spectral_content * 0.7 + min(1.0, dynamic_range) * 0.3), - "riser": min(1.0, spectral_content * 0.5 + min(1.0, dynamic_range) * 0.4 + 0.1), - "downlifter": min(1.0, spectral_content * 0.5 + min(1.0, dynamic_range) * 0.4 + 0.1), - "stutter": min(1.0, 0.3 + spectral_content * 0.4 + min(1.0, dynamic_range) * 0.3), - } - - score = suitability_scores.get(fx_type, 0.5) - recommended = score >= 0.4 and dynamic_range >= 2.0 and rms >= 0.01 - - return { - "spectral_content": round(spectral_content, 3), - "dynamic_range": round(dynamic_range, 3), - "rms": round(rms, 4), - "suitability_score": round(score, 3), - "recommended": recommended, - } - - def _build_positions(self, sections: List[Dict[str, Any]], bpm: float = 128.0) -> Dict[str, List[float]]: - """Construye posiciones de FX basandose en la estructura de secciones. - - Phase 2 Improvements: - - BPM-aware timing for musical placement - - Precise reverse placement exactly at section boundaries - - Riser ends precisely before drops for maximum impact - - Downlifter placed after drops for clean section exits - - Professional stutter placement at build peaks and drop tails - - Enhanced section type detection (intro, breakdown, peak, etc.) - - Duplicate suppression with minimum spacing - - Quality-aware source selection - - Args: - sections: Lista de secciones con kind, name, beats - bpm: BPM del proyecto para timing musical - - Returns: - Diccionario con listas de posiciones por tipo de FX - """ - reverse_positions: List[float] = [] - riser_positions: List[float] = [] - downlifter_positions: List[float] = [] - stutter_positions: List[float] = [] - - offsets = _section_offsets(sections) - beat_duration = 60.0 / max(60.0, min(200.0, bpm)) - bar_duration = beat_duration * 4.0 - - def _add_unique(positions: List[float], value: float, min_spacing: float = 2.0) -> None: - if not any(abs(p - value) < min_spacing for p in positions): - positions.append(round(max(0.0, value), 3)) - - def _section_type(section: Dict[str, Any]) -> str: - kind = str(section.get("kind", "")).lower() - name = str(section.get("name", "")).lower() - if "intro" in kind or "intro" in name: - return "intro" - if "break" in kind or "break" in name or "breakdown" in name: - return "break" - if "build" in kind or "build" in name: - return "build" - if "drop" in kind or "drop" in name: - return "drop" - if "peak" in name or "main" in name: - return "peak" - if "outro" in kind or "outro" in name: - return "outro" - if "groove" in name: - return "groove" - return kind or "unknown" - - for index, (section, start, end) in enumerate(offsets): - section_type = _section_type(section) - name = str(section.get("name", "")).lower() - span = max(1.0, end - start) - is_peak = "peak" in name or "drop b" in name or "main" in name or "peak" in section_type - is_build = section_type == "build" - is_break = section_type == "break" - is_drop = section_type == "drop" - is_outro = section_type == "outro" - is_intro = section_type == "intro" - - reverse_bar_offset = bar_duration * 1.5 - if index > 0 and is_drop: - reverse_offset = min(8.0, max(4.0, reverse_bar_offset)) - _add_unique(reverse_positions, start - reverse_offset, min_spacing=3.0) - elif index > 0 and is_break: - reverse_offset = min(6.0, max(3.0, reverse_bar_offset * 0.8)) - _add_unique(reverse_positions, start - reverse_offset, min_spacing=2.5) - elif index > 0 and is_build: - if index > 1: - reverse_offset = min(7.0, max(3.0, reverse_bar_offset)) - _add_unique(reverse_positions, start - reverse_offset, min_spacing=2.0) - - if is_build: - riser_duration = min(12.0, max(4.0, span * 0.7)) - beat_duration_seconds = beat_duration - riser_quantized = (riser_duration / beat_duration_seconds) * beat_duration_seconds - riser_quantized = max(4.0, min(12.0, riser_quantized)) - riser_start = max(start, end - riser_quantized) - _add_unique(riser_positions, riser_start, min_spacing=4.0) - - stutter_offset = bar_duration * 0.5 - stutter_start = max(start, end - stutter_offset - 0.5) - _add_unique(stutter_positions, stutter_start, min_spacing=1.5) - - if is_break and not is_peak: - downlifter_offset = bar_duration * 0.25 - _add_unique(downlifter_positions, start + downlifter_offset, min_spacing=3.0) - - elif is_drop and not is_peak: - down_offset = bar_duration * 0.3 - _add_unique(downlifter_positions, start + down_offset, min_spacing=3.0) - - if is_outro: - if span > bar_duration * 2: - _add_unique(downlifter_positions, start + bar_duration, min_spacing=3.0) - outro_down_position = start + span * 0.45 - _add_unique(downlifter_positions, outro_down_position, min_spacing=2.5) - - if is_peak and span > bar_duration: - stutter_offset = min(bar_duration * 1.5, span * 0.25) - _add_unique(stutter_positions, end - stutter_offset, min_spacing=1.5) - - if span > bar_duration * 3: - peak_stutter_position = start + span * 0.55 - _add_unique(stutter_positions, peak_stutter_position, min_spacing=bar_duration) - - if is_intro and span > bar_duration * 2: - intro_reverse_offset = bar_duration * 0.75 - _add_unique(reverse_positions, start + intro_reverse_offset, min_spacing=2.5) - - return { - "reverse": sorted(set(reverse_positions)), - "riser": sorted(set(riser_positions)), - "downlifter": sorted(set(downlifter_positions)), - "stutter": sorted(set(stutter_positions)), - } - - def build_transition_layers( - self, - reference_audio_plan: Dict[str, Any], - sections: List[Dict[str, Any]], - project_bpm: float, - variant_seed: Optional[int] = None, - ) -> List[Dict[str, Any]]: - """Construye capas de transicion desde un plan de audio de referencia. - - Args: - reference_audio_plan: Plan con matches de audio - sections: Lista de secciones del proyecto - project_bpm: BPM del proyecto - variant_seed: Semilla para variacion - - Returns: - Lista de diccionarios con info de capas generadas - """ - logger.debug("build_transition_layers called: bpm=%.1f, variant_seed=%s", project_bpm, variant_seed) - - if not isinstance(reference_audio_plan, dict): - logger.debug("reference_audio_plan is not a dict, returning empty layers") - return [] - - selected = reference_audio_plan.get("matches", {}) or {} - if not isinstance(selected, dict): - logger.debug("matches is not a dict, returning empty layers") - return [] - - # Validar project_bpm - project_bpm = max(20.0, min(300.0, float(project_bpm or 120.0))) - - variant_seed = int(variant_seed or 0) - positions = self._build_positions(sections, bpm=project_bpm) - logger.debug("Calculated FX positions: reverse=%s, riser=%s, downlifter=%s, stutter=%s", - positions["reverse"], positions["riser"], positions["downlifter"], positions["stutter"]) - layers: List[Dict[str, Any]] = [] - - FX_SOURCE_PRIORITIES = { - "reverse": [ - ("crash_fx", 0.9), - ("fill_fx", 0.85), - ("atmos_fx", 0.75), - ("synth_loop", 0.65), - ("vocal_shot", 0.55), - ], - "riser": [ - ("synth_loop", 0.9), - ("vocal_loop", 0.85), - ("atmos_fx", 0.8), - ("pad", 0.6), - ], - "downlifter": [ - ("crash_fx", 0.9), - ("atmos_fx", 0.85), - ("synth_loop", 0.7), - ("fill_fx", 0.65), - ], - "stutter": [ - ("vocal_shot", 0.95), - ("vocal_loop", 0.85), - ("snare_roll", 0.8), - ("synth_peak", 0.65), - ], - } - - FX_FALLBACK_QUERIES = { - "reverse": ["crash", "cymbal", "impact"], - "riser": ["riser", "buildup", "sweep"], - "downlifter": ["atmos", "drone", "texture"], - "stutter": ["vocal", "synth", "chord", "fx"], - } - - def _find_fallback_source(fx_type: str) -> str: - """Find source directly from SampleManager when selected is empty.""" - try: - import importlib.util - PACKAGE_DIR = Path(__file__).resolve().parent.parent - sample_manager_path = PACKAGE_DIR / "MCP_Server" / "sample_manager.py" - if sample_manager_path.exists(): - spec = importlib.util.spec_from_file_location("sample_manager", sample_manager_path) - sm_mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(sm_mod) - manager = sm_mod.get_manager() - else: - from .sample_manager import get_manager - manager = get_manager() - if manager is None: - return "" - queries = FX_FALLBACK_QUERIES.get(fx_type, []) - for query in queries: - samples = manager.search(query=query, limit=5) - for sample in samples: - path = str(sample.path) - if Path(path).exists(): - try: - audio, sr = self._load_audio(path) - if audio is not None and audio.shape[0] > 1000: - logger.debug("Fallback source %s found for %s FX", Path(path).name, fx_type) - return path - except Exception: - continue - except Exception as e: - logger.debug("Fallback search failed for %s: %s", fx_type, e) - return "" - - def find_best_source(fx_type: str) -> str: - """Find best source for FX type based on quality and priority.""" - priorities = FX_SOURCE_PRIORITIES.get(fx_type, []) - for key, base_score in priorities: - item = selected.get(key) - if isinstance(item, dict): - path = str(item.get("path", "") or "") - if path: - try: - audio, sr = self._load_audio(path) - quality = self._analyze_source_quality(audio, sr, fx_type) - if quality.get("recommended", False): - adjusted_score = base_score * quality.get("suitability_score", 0.5) - if adjusted_score >= 0.35: - logger.debug("Source %s selected for %s FX: quality=%.2f, score=%.2f", - Path(path).name, fx_type, quality.get("suitability_score", 0), adjusted_score) - return path - logger.debug("Source %s rejected for %s FX: quality=%.2f, recommended=%s", - Path(path).name, fx_type, quality.get("suitability_score", 0), quality.get("recommended")) - except Exception as e: - logger.debug("Could not analyze source %s for %s: %s", path, fx_type, e) - for key, _ in priorities: - item = selected.get(key) - if isinstance(item, dict): - path = str(item.get("path", "") or "") - if path: - return path - fallback = _find_fallback_source(fx_type) - if fallback: - logger.info("Using fallback source for %s FX: %s", fx_type, Path(fallback).name) - return fallback - - def source_path(*keys: str) -> str: - for key in keys: - item = selected.get(key) - if isinstance(item, dict): - path = str(item.get("path", "") or "") - if path: - return path - return "" - - def maybe_add(name: str, path: str, output_suffix: str, color: int, volume: float, beat_positions: List[float], renderer): - if not path or not beat_positions: - logger.debug("Skipping %s: path=%s, positions=%s", name, path if path else "(empty)", beat_positions if beat_positions else "(empty)") - return - try: - logger.debug("Generating %s from %s, duration=%.1fs, positions=%s", - name, Path(path).name, 4.0 if "REVERSE" in name else (8.0 if "RISER" in name else (6.0 if "DOWNLIFTER" in name else 2.5)), beat_positions) - rendered = renderer(path) - output_path = self._output_path(path, variant_seed, output_suffix) - file_path = self._write_audio(output_path, rendered, self.sample_rate) - logger.debug("Successfully generated %s -> %s", name, Path(file_path).name) - except Exception as exc: - logger.warning("No se pudo generar %s desde %s: %s", name, Path(path).name, exc) - logger.debug("Error details for %s: type=%s, message=%s", name, type(exc).__name__, exc) - return - layers.append({ - "name": name, - "file_path": file_path, - "positions": beat_positions, - "color": color, - "volume": volume, - "source": Path(path).name, - "generated": True, - }) - - reverse_source = find_best_source("reverse") - if reverse_source and positions["reverse"]: - maybe_add( - "AUDIO RESAMPLE REVERSE FX", - reverse_source, - "reverse_fx", - 26, - 0.58, - positions["reverse"], - lambda path: self._render_reverse_fx(path, duration_s=4.0, project_bpm=project_bpm), - ) - else: - fallback_reverse = source_path("crash_fx", "fill_fx", "atmos_fx", "synth_loop", "vocal_shot") - if fallback_reverse and positions["reverse"]: - maybe_add( - "AUDIO RESAMPLE REVERSE FX", - fallback_reverse, - "reverse_fx", - 26, - 0.58, - positions["reverse"], - lambda path: self._render_reverse_fx(path, duration_s=4.0, project_bpm=project_bpm), - ) - - riser_source = find_best_source("riser") - if riser_source and positions["riser"]: - maybe_add( - "AUDIO RESAMPLE RISER", - riser_source, - "riser_fx", - 27, - 0.54, - positions["riser"], - lambda path: self._render_riser(path, duration_s=8.0 if project_bpm >= 126 else 7.0, bpm=project_bpm), - ) - else: - fallback_riser = source_path("synth_loop", "vocal_loop", "atmos_fx", "pad") - if fallback_riser and positions["riser"]: - maybe_add( - "AUDIO RESAMPLE RISER", - fallback_riser, - "riser_fx", - 27, - 0.54, - positions["riser"], - lambda path: self._render_riser(path, duration_s=8.0 if project_bpm >= 126 else 7.0, bpm=project_bpm), - ) - - downlifter_source = find_best_source("downlifter") - if downlifter_source and positions["downlifter"]: - maybe_add( - "AUDIO RESAMPLE DOWNLIFTER", - downlifter_source, - "downlifter_fx", - 54, - 0.50, - positions["downlifter"], - lambda path: self._render_downlifter(path, duration_s=6.0, bpm=project_bpm), - ) - else: - fallback_downlifter = source_path("crash_fx", "atmos_fx", "synth_loop", "fill_fx") - if fallback_downlifter and positions["downlifter"]: - maybe_add( - "AUDIO RESAMPLE DOWNLIFTER", - fallback_downlifter, - "downlifter_fx", - 54, - 0.50, - positions["downlifter"], - lambda path: self._render_downlifter(path, duration_s=6.0, bpm=project_bpm), - ) - - stutter_source = find_best_source("stutter") - if stutter_source and positions["stutter"]: - try: - source_audio, _ = self._load_audio(stutter_source) - min_samples = 1000 - if source_audio.shape[0] < min_samples: - logger.warning("Skipping STUTTER layer: source audio too short (%d samples, min %d)", - source_audio.shape[0], min_samples) - else: - quality = self._analyze_source_quality(source_audio, self.sample_rate, "stutter") - if quality.get("suitability_score", 0) >= 0.25: - maybe_add( - "AUDIO RESAMPLE STUTTER", - stutter_source, - "stutter_fx", - 41, - 0.56, - positions["stutter"], - lambda path: self._render_stutter(path, duration_s=2.5), - ) - else: - logger.debug("STUTTER source quality too low: %.2f", quality.get("suitability_score", 0)) - except Exception as exc: - logger.warning("Skipping STUTTER layer: failed to validate source: %s", exc) - else: - fallback_stutter = source_path("vocal_shot", "vocal_loop", "snare_roll", "synth_peak") - if fallback_stutter and positions["stutter"]: - try: - source_audio, _ = self._load_audio(fallback_stutter) - min_samples = 1000 - if source_audio.shape[0] >= min_samples: - maybe_add( - "AUDIO RESAMPLE STUTTER", - fallback_stutter, - "stutter_fx", - 41, - 0.56, - positions["stutter"], - lambda path: self._render_stutter(path, duration_s=2.5), - ) - except Exception as exc: - logger.warning("Fallback STUTTER also failed: %s", exc) - - logger.info("Created %d derived layers: %s", len(layers), [layer['name'] for layer in layers]) - return layers - - def invalidate_stale_cache(self) -> int: - """Elimina entradas de cache cuyos archivos han sido modificados. - - Este metodo verifica cada entrada en el cache y elimina aquellas - donde el archivo tiene un mtime diferente al que esta en la key. - - Nota: Con el diseno actual donde mtime es parte de la key, las - entradas stale naturalmente expiran por LRU. Este metodo es - utilitario para limpieza proactiva. - - Returns: - Numero de entradas eliminadas - """ - removed = 0 - keys_to_remove: List[str] = [] - - for key in list(self._audio_cache.keys()): - # Extraer path de la key (formato: "path::mtime_ns" o solo "path") - if "::" in key: - path_str, _ = key.rsplit("::", 1) - else: - path_str = key - - path = Path(path_str) - - # Verificar si el archivo aun existe y tiene el mismo mtime - if not path.exists(): - # Archivo eliminado, marcar para remover - keys_to_remove.append(key) - removed += 1 - continue - - try: - current_mtime_ns = path.stat().st_mtime_ns - # Reconstruir la key esperada con el mtime actual - expected_key = self._get_cache_key(path_str, current_mtime_ns) - - # Si la key actual no coincide con la esperada, el archivo cambio - if key != expected_key: - keys_to_remove.append(key) - removed += 1 - except OSError: - # Error al acceder al archivo, marcar para remover - keys_to_remove.append(key) - removed += 1 - - # Remover las entradas stale - for key in keys_to_remove: - del self._audio_cache[key] - - if removed > 0: - logger.debug("Invalidadas %d entradas de cache stale", removed) - - return removed - - def clear_cache(self) -> int: - """Limpia el cache de audio y devuelve el numero de entradas eliminadas. - - Returns: - Numero de entradas que fueron eliminadas del cache - """ - count = len(self._audio_cache) - self._audio_cache.clear() - self._cache_sizes.clear() - self._cache_total_bytes = 0 - self._cache_hits = 0 - self._cache_misses = 0 - return count - - def cache_size(self) -> int: - """Devuelve el numero de archivos en cache. - - Returns: - Numero de entradas en cache - """ - return len(self._audio_cache) - - def cache_stats(self) -> Dict[str, Any]: - """Devuelve estadisticas del cache de audio. - - Phase 1 Improvement: Metodo nuevo para monitorear rendimiento del cache. - - Returns: - Diccionario con estadisticas: entries, bytes, hits, misses, hit_rate - """ - total_requests = self._cache_hits + self._cache_misses - hit_rate = self._cache_hits / total_requests if total_requests > 0 else 0.0 - - return { - "entries": len(self._audio_cache), - "max_entries": self._CACHE_LIMIT, - "bytes": self._cache_total_bytes, - "max_bytes": self._CACHE_MAX_SIZE_BYTES, - "mb": round(self._cache_total_bytes / (1024 * 1024), 2), - "hits": self._cache_hits, - "misses": self._cache_misses, - "hit_rate": round(hit_rate, 3), - "max_age_s": self._CACHE_MAX_AGE_S, - } diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/enhanced_device_automation.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/enhanced_device_automation.py deleted file mode 100644 index 213cb15..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/enhanced_device_automation.py +++ /dev/null @@ -1,431 +0,0 @@ -""" -Enhanced Device Automation for Timbral Movement Between Sections. -This module provides expanded device automation parameters for musical variation. -""" - -# ============================================================================= -# ENHANCED SECTION DEVICE AUTOMATION - More timbral color per section -# ============================================================================= - -# Automatizacion de devices en tracks individuales por rol - ENHANCED -SECTION_DEVICE_AUTOMATION = { - # BASS - Filtros, drive y compresion dinamica - 'bass': { - 'Saturator': { - 'Drive': {'intro': 1.5, 'build': 3.5, 'drop': 5.0, 'break': 2.0, 'outro': 1.8}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.30, 'break': 0.15, 'outro': 0.10}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6200.0, 'build': 8500.0, 'drop': 12000.0, 'break': 4800.0, 'outro': 5800.0}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.06}, - 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, - }, - 'Compressor': { - 'Threshold': {'intro': -12.0, 'build': -14.0, 'drop': -18.0, 'break': -10.0, 'outro': -11.0}, - 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.0, 'outro': 2.2}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, - }, - }, - 'sub_bass': { - 'Saturator': { - 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 5200.0, 'build': 7200.0, 'drop': 10000.0, 'break': 4200.0, 'outro': 4800.0}, - 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.06, 'break': 0.14, 'outro': 0.04}, - }, - 'Utility': { - 'Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, - 'Gain': {'intro': 0.0, 'build': 0.2, 'drop': 0.4, 'break': -0.2, 'outro': 0.0}, - }, - }, - # PAD - Filtros envolventes con width y reverb - 'pad': { - 'Auto Filter': { - 'Frequency': {'intro': 4500.0, 'build': 8000.0, 'drop': 11000.0, 'break': 3200.0, 'outro': 4000.0}, - 'Dry/Wet': {'intro': 0.25, 'build': 0.18, 'drop': 0.12, 'break': 0.35, 'outro': 0.28}, - 'Resonance': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.22, 'build': 0.16, 'drop': 0.10, 'break': 0.28, 'outro': 0.24}, - 'Decay Time': {'intro': 3.5, 'build': 2.8, 'drop': 2.0, 'break': 4.2, 'outro': 3.8}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.85, 'build': 1.02, 'drop': 1.12, 'break': 1.25, 'outro': 0.90}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 0.6, 'outro': 0.7}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.20, 'break': 0.08, 'outro': 0.12}, - }, - }, - # ATMOS - Filtros espaciales con movement - 'atmos': { - 'Auto Filter': { - 'Frequency': {'intro': 3800.0, 'build': 7200.0, 'drop': 9800.0, 'break': 2800.0, 'outro': 3500.0}, - 'Dry/Wet': {'intro': 0.30, 'build': 0.22, 'drop': 0.15, 'break': 0.40, 'outro': 0.32}, - 'Resonance': {'intro': 0.22, 'build': 0.32, 'drop': 0.18, 'break': 0.42, 'outro': 0.25}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.35, 'build': 0.28, 'drop': 0.18, 'break': 0.42, 'outro': 0.38}, - 'Decay Time': {'intro': 4.0, 'build': 3.2, 'drop': 2.2, 'break': 5.0, 'outro': 4.5}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.70, 'build': 0.88, 'drop': 1.05, 'break': 1.20, 'outro': 0.75}, - }, - }, - # FX ELEMENTS - 'reverse_fx': { - 'Auto Filter': { - 'Frequency': {'intro': 5200.0, 'build': 9000.0, 'drop': 12000.0, 'break': 6000.0, 'outro': 4800.0}, - 'Dry/Wet': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.30, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, - 'Decay Time': {'intro': 3.0, 'build': 4.5, 'drop': 2.5, 'break': 5.5, 'outro': 3.5}, - }, - 'Saturator': { - 'Drive': {'intro': 1.2, 'build': 2.8, 'drop': 4.5, 'break': 1.8, 'outro': 1.0}, - }, - }, - 'riser': { - 'Auto Filter': { - 'Frequency': {'intro': 4000.0, 'build': 10000.0, 'drop': 14000.0, 'break': 5500.0, 'outro': 4200.0}, - 'Dry/Wet': {'intro': 0.15, 'build': 0.30, 'drop': 0.12, 'break': 0.22, 'outro': 0.18}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.25, 'build': 0.40, 'drop': 0.22, 'break': 0.35, 'outro': 0.20}, - 'Decay Time': {'intro': 2.5, 'build': 5.0, 'drop': 3.0, 'break': 4.0, 'outro': 2.8}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.18, 'build': 0.35, 'drop': 0.15, 'break': 0.25, 'outro': 0.15}, - 'Feedback': {'intro': 0.30, 'build': 0.55, 'drop': 0.25, 'break': 0.45, 'outro': 0.28}, - }, - 'Saturator': { - 'Drive': {'intro': 1.5, 'build': 4.0, 'drop': 3.0, 'break': 2.5, 'outro': 1.2}, - }, - }, - 'impact': { - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.12, 'break': 0.20, 'outro': 0.14}, - 'Decay Time': {'intro': 2.0, 'build': 2.5, 'drop': 1.8, 'break': 3.0, 'outro': 2.2}, - }, - 'Saturator': { - 'Drive': {'intro': 1.8, 'build': 2.5, 'drop': 3.5, 'break': 2.0, 'outro': 1.5}, - }, - }, - 'drone': { - 'Auto Filter': { - 'Frequency': {'intro': 3000.0, 'build': 6500.0, 'drop': 9000.0, 'break': 2500.0, 'outro': 2800.0}, - 'Dry/Wet': {'intro': 0.20, 'build': 0.15, 'drop': 0.10, 'break': 0.30, 'outro': 0.22}, - 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.22, 'break': 0.40, 'outro': 0.28}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.18, 'build': 0.14, 'drop': 0.08, 'break': 0.25, 'outro': 0.20}, - 'Decay Time': {'intro': 4.5, 'build': 3.5, 'drop': 2.5, 'break': 5.5, 'outro': 4.8}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 0.6, 'outro': 0.7}, - }, - }, - # HATS - Filtros de brillantez con resonance y saturacion - 'hat_closed': { - 'Auto Filter': { - 'Frequency': {'intro': 12000.0, 'build': 14000.0, 'drop': 16000.0, 'break': 10000.0, 'outro': 11000.0}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.14}, - 'Resonance': {'intro': 0.15, 'build': 0.25, 'drop': 0.12, 'outro': 0.18, 'break': 0.30}, - }, - 'Saturator': { - 'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 1.8, 'break': 0.8, 'outro': 0.6}, - }, - }, - 'hat_open': { - 'Auto Filter': { - 'Frequency': {'intro': 9000.0, 'build': 11000.0, 'drop': 13000.0, 'break': 7500.0, 'outro': 8500.0}, - 'Dry/Wet': {'intro': 0.18, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.20}, - 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.22, 'outro': 0.12}, - }, - }, - 'top_loop': { - 'Auto Filter': { - 'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 12500.0, 'break': 7000.0, 'outro': 8000.0}, - 'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.16, 'break': 0.32, 'outro': 0.22}, - 'Resonance': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'outro': 0.15, 'break': 0.28}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.05, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.10}, - }, - }, - # SYNTHS - 'chords': { - 'Auto Filter': { - 'Frequency': {'intro': 5500.0, 'build': 8500.0, 'drop': 11000.0, 'break': 4000.0, 'outro': 5000.0}, - 'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.12, 'break': 0.28, 'outro': 0.18}, - 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, - 'Feedback': {'intro': 0.25, 'build': 0.40, 'drop': 0.30, 'break': 0.45, 'outro': 0.28}, - }, - 'Saturator': { - 'Drive': {'intro': 1.2, 'build': 2.2, 'drop': 3.5, 'break': 1.5, 'outro': 1.0}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.95, 'build': 1.05, 'drop': 1.15, 'break': 1.25, 'outro': 1.00}, - }, - }, - 'lead': { - 'Saturator': { - 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.20, 'drop': 0.25, 'break': 0.10, 'outro': 0.15}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.18, 'outro': 0.10}, - 'Feedback': {'intro': 0.20, 'build': 0.35, 'drop': 0.28, 'break': 0.40, 'outro': 0.22}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 4500.0, 'outro': 5500.0}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.90, 'build': 1.02, 'drop': 1.10, 'break': 1.18, 'outro': 0.95}, - }, - }, - 'stab': { - 'Saturator': { - 'Drive': {'intro': 2.0, 'build': 3.5, 'drop': 5.0, 'break': 2.5, 'outro': 2.2}, - 'Dry/Wet': {'intro': 0.18, 'build': 0.25, 'drop': 0.30, 'break': 0.15, 'outro': 0.20}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 12000.0, 'break': 5000.0, 'outro': 5500.0}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.88, 'build': 1.00, 'drop': 1.12, 'break': 1.20, 'outro': 0.92}, - }, - }, - 'pluck': { - 'Echo': { - 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.15}, - 'Feedback': {'intro': 0.30, 'build': 0.45, 'drop': 0.35, 'break': 0.50, 'outro': 0.32}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 7000.0, 'build': 10000.0, 'drop': 13000.0, 'break': 5500.0, 'outro': 6500.0}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 1.2, 'outro': 0.9}, - }, - }, - 'arp': { - 'Echo': { - 'Dry/Wet': {'intro': 0.15, 'build': 0.28, 'drop': 0.18, 'break': 0.35, 'outro': 0.18}, - 'Feedback': {'intro': 0.35, 'build': 0.50, 'drop': 0.40, 'break': 0.58, 'outro': 0.38}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 5000.0, 'outro': 6000.0}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.14, 'break': 0.25, 'outro': 0.15}, - }, - 'Saturator': { - 'Drive': {'intro': 0.6, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.7}, - }, - }, - 'counter': { - 'Echo': { - 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.12}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6000.0, 'build': 8800.0, 'drop': 11500.0, 'break': 4800.0, 'outro': 5200.0}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.12, 'break': 0.22, 'outro': 0.14}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.75, 'build': 0.92, 'drop': 1.08, 'break': 1.15, 'outro': 0.80}, - }, - }, - # VOCAL - 'vocal': { - 'Echo': { - 'Dry/Wet': {'intro': 0.12, 'build': 0.25, 'drop': 0.15, 'break': 0.30, 'outro': 0.14}, - 'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.30, 'break': 0.48, 'outro': 0.28}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.06, 'break': 0.18, 'outro': 0.10}, - 'Decay Time': {'intro': 2.5, 'build': 3.5, 'drop': 2.0, 'break': 4.0, 'outro': 2.8}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 11000.0, 'break': 5000.0, 'outro': 5500.0}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.5, 'break': 1.2, 'outro': 0.9}, - }, - }, - # DRUMS - Sin automatizacion de devices (manejados por volumen/sends) - 'kick': {}, - 'clap': {}, - 'snare_fill': {}, - 'perc': {}, - 'ride': {}, - 'tom_fill': {}, - 'crash': {}, - 'sc_trigger': {}, -} - -# ============================================================================= -# ENHANCED BUS DEVICE AUTOMATION - More drive/compression per section -# ============================================================================= - -BUS_DEVICE_AUTOMATION = { - 'drums': { - 'Compressor': { - 'Threshold': {'intro': -14.0, 'build': -16.0, 'drop': -18.5, 'break': -12.0, 'outro': -13.5}, - 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.2, 'outro': 2.4}, - 'Attack': {'intro': 0.015, 'build': 0.010, 'drop': 0.005, 'break': 0.020, 'outro': 0.018}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.9}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.10, 'outro': 0.10}, - }, - 'Limiter': { - 'Gain': {'intro': 0.2, 'build': 0.3, 'drop': 0.5, 'break': 0.15, 'outro': 0.18}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 8500.0, 'build': 10000.0, 'drop': 14000.0, 'break': 6500.0, 'outro': 7500.0}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.10, 'drop': 0.05, 'break': 0.18, 'outro': 0.14}, - }, - }, - 'bass': { - 'Saturator': { - 'Drive': {'intro': 1.0, 'build': 2.0, 'drop': 3.5, 'break': 1.5, 'outro': 1.2}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.12, 'outro': 0.10}, - }, - 'Compressor': { - 'Threshold': {'intro': -15.0, 'build': -17.0, 'drop': -20.0, 'break': -14.0, 'outro': -14.5}, - 'Ratio': {'intro': 3.0, 'build': 3.5, 'drop': 4.5, 'break': 2.8, 'outro': 3.0}, - 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.008, 'break': 0.025, 'outro': 0.022}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 5000.0, 'build': 7000.0, 'drop': 10000.0, 'break': 4500.0, 'outro': 5200.0}, - 'Dry/Wet': {'intro': 0.05, 'build': 0.08, 'drop': 0.12, 'break': 0.10, 'outro': 0.06}, - }, - }, - 'music': { - 'Compressor': { - 'Threshold': {'intro': -19.0, 'build': -20.0, 'drop': -22.0, 'break': -18.0, 'outro': -18.5}, - 'Ratio': {'intro': 2.0, 'build': 2.5, 'drop': 3.0, 'break': 1.8, 'outro': 2.0}, - 'Attack': {'intro': 0.025, 'build': 0.020, 'drop': 0.015, 'break': 0.030, 'outro': 0.028}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 8000.0, 'build': 11000.0, 'drop': 14000.0, 'break': 6000.0, 'outro': 7500.0}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.05, 'drop': 0.03, 'break': 0.12, 'outro': 0.10}, - }, - 'Utility': { - 'Stereo Width': {'intro': 1.05, 'build': 1.10, 'drop': 1.12, 'break': 1.18, 'outro': 1.08}, - }, - 'Saturator': { - 'Drive': {'intro': 0.3, 'build': 0.8, 'drop': 1.5, 'break': 0.4, 'outro': 0.35}, - 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.15, 'break': 0.08, 'outro': 0.06}, - }, - }, - 'vocal': { - 'Echo': { - 'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.05, 'break': 0.15, 'outro': 0.08}, - 'Feedback': {'intro': 0.25, 'build': 0.38, 'drop': 0.28, 'break': 0.45, 'outro': 0.30}, - }, - 'Compressor': { - 'Threshold': {'intro': -16.0, 'build': -17.0, 'drop': -19.0, 'break': -15.0, 'outro': -15.5}, - 'Ratio': {'intro': 2.8, 'build': 3.2, 'drop': 3.8, 'break': 2.5, 'outro': 2.7}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.04, 'build': 0.08, 'drop': 0.03, 'break': 0.12, 'outro': 0.06}, - 'Decay Time': {'intro': 2.0, 'build': 2.8, 'drop': 1.5, 'break': 3.5, 'outro': 2.5}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 13000.0, 'break': 7200.0, 'outro': 8000.0}, - 'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.04, 'break': 0.14, 'outro': 0.08}, - }, - }, - 'fx': { - 'Auto Filter': { - 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12000.0, 'break': 5500.0, 'outro': 6000.0}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.10, 'drop': 0.06, 'break': 0.18, 'outro': 0.14}, - 'Resonance': {'intro': 0.15, 'build': 0.22, 'drop': 0.12, 'break': 0.28, 'outro': 0.18}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.10, 'break': 0.22, 'outro': 0.16}, - 'Decay Time': {'intro': 2.5, 'build': 3.2, 'drop': 2.0, 'break': 4.0, 'outro': 3.0}, - }, - 'Limiter': { - 'Gain': {'intro': -0.2, 'build': 0.0, 'drop': 0.2, 'break': -0.3, 'outro': -0.1}, - }, - 'Saturator': { - 'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 2.0, 'break': 0.8, 'outro': 0.6}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.12, 'drop': 0.18, 'break': 0.10, 'outro': 0.10}, - }, - }, -} - -# ============================================================================= -# ENHANCED MASTER Device Automation - Section Energy Response -# ============================================================================= - -MASTER_DEVICE_AUTOMATION = { - 'Utility': { - 'Stereo Width': {'intro': 1.04, 'build': 1.08, 'drop': 1.10, 'break': 1.12, 'outro': 1.06}, - 'Gain': {'intro': 0.6, 'build': 0.8, 'drop': 1.0, 'break': 0.5, 'outro': 0.5}, - }, - 'Saturator': { - 'Drive': {'intro': 0.2, 'build': 0.35, 'drop': 0.5, 'break': 0.15, 'outro': 0.18}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.08, 'outro': 0.12}, - }, - 'Compressor': { - 'Ratio': {'intro': 0.55, 'build': 0.62, 'drop': 0.70, 'break': 0.50, 'outro': 0.52}, - 'Threshold': {'intro': -10.0, 'build': -12.0, 'drop': -14.0, 'break': -8.0, 'outro': -9.0}, - 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.010, 'break': 0.025, 'outro': 0.022}, - 'Release': {'intro': 0.15, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.16}, - }, - 'Limiter': { - 'Gain': {'intro': 1.0, 'build': 1.2, 'drop': 1.4, 'break': 0.9, 'outro': 0.95}, - 'Ceiling': {'intro': -0.5, 'build': -0.8, 'drop': -1.0, 'break': -0.3, 'outro': -0.4}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 8000.0, 'build': 11000.0, 'drop': 15000.0, 'break': 6000.0, 'outro': 7000.0}, - 'Dry/Wet': {'intro': 0.05, 'build': 0.03, 'drop': 0.02, 'break': 0.08, 'outro': 0.06}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.02, 'build': 0.06, 'drop': 0.04, 'break': 0.08, 'outro': 0.04}, - 'Feedback': {'intro': 0.15, 'build': 0.28, 'drop': 0.20, 'break': 0.32, 'outro': 0.22}, - }, -} - -# Safety clamps for device parameters to prevent extreme values -DEVICE_PARAMETER_SAFETY_CLAMPS = { - 'Drive': {'min': 0.0, 'max': 6.0}, - 'Frequency': {'min': 20.0, 'max': 20000.0}, - 'Dry/Wet': {'min': 0.0, 'max': 1.0}, - 'Feedback': {'min': 0.0, 'max': 0.7}, - 'Stereo Width': {'min': 0.0, 'max': 1.3}, - 'Resonance': {'min': 0.0, 'max': 1.0}, - 'Ratio': {'min': 1.0, 'max': 20.0}, - 'Threshold': {'min': -60.0, 'max': 0.0}, - 'Attack': {'min': 0.0001, 'max': 0.5}, - 'Release': {'min': 0.001, 'max': 2.0}, - 'Gain': {'min': -1.0, 'max': 1.8}, - 'Decay Time': {'min': 0.1, 'max': 10.0}, -} - -MASTER_SAFETY_CLAMPS = { - 'Stereo Width': {'min': 0.0, 'max': 1.25}, - 'Drive': {'min': 0.0, 'max': 1.5}, - 'Ratio': {'min': 0.45, 'max': 0.9}, - 'Gain': {'min': 0.0, 'max': 1.6}, - 'Attack': {'min': 0.0001, 'max': 0.1}, - 'Ceiling': {'min': -3.0, 'max': 0.0}, -} \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/fx_group_loader.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/fx_group_loader.py deleted file mode 100644 index 6c7e6ec..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/fx_group_loader.py +++ /dev/null @@ -1,170 +0,0 @@ -import json -import socket -from datetime import datetime - -LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\fx_group.txt" - -def log(msg): - timestamp = datetime.now().isoformat() - entry = f"[{timestamp}] {msg}" - print(entry) - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(entry + "\n") - -class AbletonSocketClient: - def __init__(self, host="127.0.0.1", port=9877, timeout=30.0): - self.host = host - self.port = port - self.timeout = timeout - - def send(self, command_type, params=None): - payload = json.dumps({ - "type": command_type, - "params": params or {}, - }).encode("utf-8") + b"\n" - - with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: - sock.sendall(payload) - reader = sock.makefile("r", encoding="utf-8") - try: - line = reader.readline() - finally: - reader.close() - try: - sock.shutdown(socket.SHUT_RDWR) - except OSError: - pass - - if not line: - raise RuntimeError(f"No response for command: {command_type}") - return json.loads(line) - -def set_input_routing(client, track_index, routing_name): - result = client.send("set_track_input_routing", { - "index": track_index, - "routing_name": routing_name - }) - return result - -def main(): - log("=" * 60) - log("FX GROUP - TRANSITION FX LOADER") - log("=" * 60) - - client = AbletonSocketClient() - - RISER_TRACK = 20 - DOWNLIFTER_TRACK = 21 - CRASH_TRACK = 22 - IMPACT_TRACK = 23 - NOISE_TRACK = 24 - REVERSE_TRACK = 25 - - RISER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\textures\fx\BBH - Primer Impacto -Risers 1.wav" - DOWNLIFTER_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks\BBH - Primer Impacto -Downfilters 1.wav" - CRASH_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\fx\BBH - Primer Impacto - Crash 2.wav" - IMPACT_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\oneshots\fx\BBH - Primer Impacto -Impact 1.wav" - NOISE_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks\EFX_01_Em_125.wav" - REVERSE_PATH = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\organized_samples\textures\fx\BBH - Primer Impacto -Risers 4.wav" - - RISER_POSITIONS = [14, 46, 78, 110, 142, 174] - DOWNLIFTER_POSITIONS = [16, 48, 80, 112, 144, 176] - CRASH_POSITIONS = [0, 32, 64, 96, 128, 160, 192] - IMPACT_POSITIONS = [16, 48, 80, 112, 144] - NOISE_POSITIONS = [14, 46, 78, 110, 142, 174] - REVERSE_POSITIONS = [14, 30, 62, 94, 126] - - log(f"Track indices:") - log(f" RISER={RISER_TRACK}, DOWNLIFTER={DOWNLIFTER_TRACK}, CRASH={CRASH_TRACK}") - log(f" IMPACT={IMPACT_TRACK}, NOISE={NOISE_TRACK}, REVERSE={REVERSE_TRACK}") - - log("") - log("Step 1: Placing RISER samples...") - log(f" Positions: {RISER_POSITIONS}") - log(f" File: {RISER_PATH}") - result = client.send("create_arrangement_audio_pattern", { - "track_index": RISER_TRACK, - "file_path": RISER_PATH, - "positions": RISER_POSITIONS, - "name": "RISER FX" - }) - log(f" Result: {json.dumps(result, indent=2)}") - - log("") - log("Step 2: Placing DOWNLIFTER samples...") - log(f" Positions: {DOWNLIFTER_POSITIONS}") - log(f" File: {DOWNLIFTER_PATH}") - result = client.send("create_arrangement_audio_pattern", { - "track_index": DOWNLIFTER_TRACK, - "file_path": DOWNLIFTER_PATH, - "positions": DOWNLIFTER_POSITIONS, - "name": "DOWNLIFTER FX" - }) - log(f" Result: {json.dumps(result, indent=2)}") - - log("") - log("Step 3: Placing CRASH samples...") - log(f" Positions: {CRASH_POSITIONS}") - log(f" File: {CRASH_PATH}") - result = client.send("create_arrangement_audio_pattern", { - "track_index": CRASH_TRACK, - "file_path": CRASH_PATH, - "positions": CRASH_POSITIONS, - "name": "CRASH FX" - }) - log(f" Result: {json.dumps(result, indent=2)}") - - log("") - log("Step 4: Placing IMPACT samples...") - log(f" Positions: {IMPACT_POSITIONS}") - log(f" File: {IMPACT_PATH}") - result = client.send("create_arrangement_audio_pattern", { - "track_index": IMPACT_TRACK, - "file_path": IMPACT_PATH, - "positions": IMPACT_POSITIONS, - "name": "IMPACT FX" - }) - log(f" Result: {json.dumps(result, indent=2)}") - - log("") - log("Step 5: Placing NOISE SWEEP samples...") - log(f" Positions: {NOISE_POSITIONS}") - log(f" File: {NOISE_PATH}") - result = client.send("create_arrangement_audio_pattern", { - "track_index": NOISE_TRACK, - "file_path": NOISE_PATH, - "positions": NOISE_POSITIONS, - "name": "NOISE FX" - }) - log(f" Result: {json.dumps(result, indent=2)}") - - log("") - log("Step 6: Placing REVERSE FX samples...") - log(f" Positions: {REVERSE_POSITIONS}") - log(f" File: {REVERSE_PATH}") - result = client.send("create_arrangement_audio_pattern", { - "track_index": REVERSE_TRACK, - "file_path": REVERSE_PATH, - "positions": REVERSE_POSITIONS, - "name": "REVERSE FX" - }) - log(f" Result: {json.dumps(result, indent=2)}") - - log("") - log("=" * 60) - log("Setting input routing to 'No Input' for all FX tracks...") - log("=" * 60) - - for track_idx, track_name in [(RISER_TRACK, "RISER"), (DOWNLIFTER_TRACK, "DOWNLIFTER"), - (CRASH_TRACK, "CRASH"), (IMPACT_TRACK, "IMPACT"), - (NOISE_TRACK, "NOISE SWEEP"), (REVERSE_TRACK, "REVERSE FX")]: - result = set_input_routing(client, track_idx, "No Input") - log(f" {track_name} (track {track_idx}): {result}") - - log("") - log("=" * 60) - log("FX GROUP COMPLETE") - log("=" * 60) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_listener.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_listener.py deleted file mode 100644 index 2eeb6a4..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_listener.py +++ /dev/null @@ -1,4834 +0,0 @@ -""" -reference_listener.py - Reference-track audio analysis and sample matching. - -Improved for Phase 4: -- Enhanced section detection (intro, verse, build, drop, break, outro) -- Better role detection per segment -- Precise one-shot vs loop classification -- Improved clap, hat, bass loop, vocal, fx detection -- Family repetition penalty system -""" - -from __future__ import annotations - -import json -import logging -import math -import random -import warnings -import gzip -import hashlib -import time -from collections import defaultdict, deque -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import numpy as np - -try: - import librosa -except ImportError: # pragma: no cover - librosa = None - -try: - import torch - import torch.nn.functional as F -except ImportError: # pragma: no cover - torch = None - F = None - -try: - import torch_directml -except ImportError: # pragma: no cover - torch_directml = None - - -logger = logging.getLogger("ReferenceListener") - -NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] -KEY_PROFILES = { - 'major': [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': [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17], -} - -_cross_generation_reference_family_memory: Dict[str, int] = defaultdict(int) -_cross_generation_reference_path_memory: Dict[str, int] = defaultdict(int) - -# Section type definitions with characteristic energy patterns -# Enhanced with clearer energy thresholds and additional features for robust detection -SECTION_PROFILES = { - 'intro': { - 'energy_range': (0.0, 0.35), - 'onset_density': (0.0, 0.4), - 'spectral_brightness': (0.0, 0.5), - 'energy_stability': (0.4, 1.0), - 'typical_position': (0.0, 0.15), - 'min_bars': 4, - 'max_bars': 32, - }, - 'verse': { - 'energy_range': (0.25, 0.55), - 'onset_density': (0.3, 0.6), - 'spectral_brightness': (0.3, 0.6), - 'energy_stability': (0.5, 1.0), - 'typical_position': (0.1, 0.7), - 'min_bars': 8, - 'max_bars': 32, - }, - 'build': { - 'energy_range': (0.45, 0.85), - 'onset_density': (0.5, 0.9), - 'spectral_brightness': (0.5, 0.8), - 'energy_stability': (0.0, 0.6), - 'energy_slope': (0.05, 1.0), - 'typical_position': (0.15, 0.85), - 'min_bars': 4, - 'max_bars': 24, - 'rising': True, - }, - 'drop': { - 'energy_range': (0.65, 1.0), - 'onset_density': (0.5, 1.0), - 'spectral_brightness': (0.5, 1.0), - 'energy_stability': (0.5, 1.0), - 'typical_position': (0.2, 0.9), - 'min_bars': 8, - 'max_bars': 64, - }, - 'break': { - 'energy_range': (0.1, 0.45), - 'onset_density': (0.1, 0.4), - 'spectral_brightness': (0.2, 0.5), - 'energy_stability': (0.4, 1.0), - 'typical_position': (0.3, 0.7), - 'min_bars': 4, - 'max_bars': 24, - }, - 'outro': { - 'energy_range': (0.05, 0.4), - 'onset_density': (0.05, 0.5), - 'spectral_brightness': (0.1, 0.4), - 'energy_stability': (0.0, 0.6), - 'energy_slope': (-1.0, -0.02), - 'typical_position': (0.82, 1.0), - 'min_bars': 4, - 'max_bars': 32, - 'falling': True, - }, -} - -SECTION_CONFIDENCE_THRESHOLDS = { - 'high': 0.75, - 'medium': 0.55, - 'low': 0.35, - 'ambiguous': 0.20, -} - -# Spectral signatures for role detection -SPECTRAL_ROLE_SIGNATURES = { - 'kick': {'centroid_range': (50, 400), 'rolloff_range': (200, 2000), 'rms_spread': (0.4, 1.0), 'transient_score': (0.6, 1.0)}, - 'clap': {'centroid_range': (800, 4000), 'rolloff_range': (2000, 8000), 'rms_spread': (0.2, 0.7), 'transient_score': (0.7, 1.0)}, - 'hat': {'centroid_range': (4000, 12000), 'rolloff_range': (6000, 14000), 'rms_spread': (0.1, 0.4), 'transient_score': (0.5, 1.0)}, - 'bass_loop': {'centroid_range': (60, 500), 'rolloff_range': (200, 2000), 'rms_spread': (0.5, 1.0), 'periodicity': (0.6, 1.0)}, - 'vocal': {'centroid_range': (200, 3000), 'rolloff_range': (1000, 5000), 'rms_spread': (0.3, 0.8), 'harmonic_ratio': (0.4, 0.9)}, - 'fx': {'centroid_range': (1000, 8000), 'rolloff_range': (3000, 12000), 'rms_spread': (0.2, 0.9), 'spectral_flux': (0.5, 1.0)}, -} - -# Roles elegibles para variación por sección -# Estos roles pueden usar diferentes samples en diferentes secciones -SECTION_VARIATION_ROLES = [ - 'perc', 'perc_alt', 'top_loop', 'vocal_shot', 'synth_peak', 'atmos' -] - -# Variaciones permitidas por tipo de sección -SECTION_VARIANTS = { - 'intro': ['sparse', 'minimal'], - 'verse': ['standard', 'sparse'], - 'build': ['building', 'dense'], - 'drop': ['full', 'peak'], - 'break': ['sparse', 'atmospheric'], - 'outro': ['fading', 'minimal'] -} - -ROLE_VECTOR_TYPES = { - 'kick': set(), - 'snare': set(), - 'hat': set(), - 'bass_loop': {'bass'}, - 'perc_loop': {'drum loop', 'top'}, - 'top_loop': {'top', 'drum loop'}, - 'synth_loop': {'synth loop', 'synth'}, - 'vocal_loop': {'vocal'}, - 'crash_fx': {'fx'}, - 'fill_fx': {'fx'}, - 'snare_roll': {'fx'}, - 'atmos_fx': {'fx', 'synth'}, - 'vocal_shot': {'vocal'}, -} - -ROLE_SEGMENT_SETTINGS = { - 'kick': {'windows': {1.0, 2.0}, 'section_kinds': {'intro', 'verse', 'build', 'drop'}, 'top_k': 10}, - 'snare': {'windows': {1.0, 2.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 10}, - 'hat': {'windows': {1.0, 2.0}, 'section_kinds': {'intro', 'verse', 'build', 'drop'}, 'top_k': 12}, - 'bass_loop': {'windows': {4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, - 'perc_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, - 'top_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, - 'synth_loop': {'windows': {4.0, 8.0}, 'section_kinds': {'build', 'drop', 'break'}, 'top_k': 8}, - 'vocal_loop': {'windows': {2.0, 4.0, 8.0}, 'section_kinds': {'verse', 'build', 'drop', 'break'}, 'top_k': 8}, - 'crash_fx': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'drop', 'intro', 'outro'}, 'top_k': 6}, - 'fill_fx': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'break', 'drop'}, 'top_k': 6}, - 'snare_roll': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'build', 'drop'}, 'top_k': 6}, - 'atmos_fx': {'windows': {4.0, 8.0}, 'section_kinds': {'intro', 'break', 'outro'}, 'top_k': 6}, - 'vocal_shot': {'windows': {1.0, 2.0, 4.0}, 'section_kinds': {'verse', 'build', 'drop'}, 'top_k': 8}, -} - -ROLE_DURATION_WINDOWS = { - 'kick': (0.05, 2.5), - 'snare': (0.05, 3.0), - 'hat': (0.05, 2.0), - 'bass_loop': (0.75, 32.0), - 'perc_loop': (0.75, 32.0), - 'top_loop': (0.75, 32.0), - 'synth_loop': (0.75, 32.0), - 'vocal_loop': (0.75, 32.0), - 'crash_fx': (0.05, 12.0), - 'fill_fx': (0.15, 12.0), - 'snare_roll': (0.15, 12.0), - 'atmos_fx': (0.25, 32.0), - 'vocal_shot': (0.05, 3.5), -} - - -def _safe_float(value: Any, default: float = 0.0) -> float: - try: - return float(np.atleast_1d(value)[0]) - except Exception: - return float(default) - - -def _normalize_chroma(chroma: np.ndarray) -> np.ndarray: - chroma = np.asarray(chroma, dtype=np.float32).reshape(12) - total = float(np.sum(chroma)) - if total <= 1e-9: - return chroma - return chroma / total - - -def _adaptive_n_fft(audio_length: int, default_n_fft: int = 2048, min_n_fft: int = 512) -> int: - """Calcula n_fft adaptativo basado en la longitud del audio.""" - max_n_fft = audio_length // 2 - adaptive = max(min_n_fft, min(default_n_fft, max_n_fft)) - if adaptive < default_n_fft: - logger.debug("Using reduced n_fft=%d for short audio (len=%d)", adaptive, audio_length) - return adaptive - - -def _detect_key(chroma: np.ndarray) -> Tuple[Optional[str], float]: - chroma = _normalize_chroma(chroma) - best_key = None - best_score = -999.0 - - for mode, profile in KEY_PROFILES.items(): - profile_array = np.asarray(profile, dtype=np.float32) - for index in range(12): - score = np.corrcoef(chroma, np.roll(profile_array, index))[0, 1] - if np.isnan(score): - continue - if score > best_score: - best_score = float(score) - best_key = NOTE_NAMES[index] + ('m' if mode == 'minor' else '') - - return best_key, best_score if best_key else 0.0 - - -def _key_distance(left: Optional[str], right: Optional[str]) -> int: - if not left or not right: - return 6 - - def _index(key_name: str) -> int: - base = key_name[:-1] if key_name.endswith('m') else key_name - return NOTE_NAMES.index(base) if base in NOTE_NAMES else 0 - - return min((_index(left) - _index(right)) % 12, (_index(right) - _index(left)) % 12) - - -class SectionDetector: - """Detects structural sections from audio analysis with improved segmentation.""" - - def __init__(self, hop_length: int = 512, sr: int = 22050): - self.hop_length = hop_length - self.sr = sr - self.min_section_bars = 4 - self.max_section_bars = 64 - self.min_section_seconds = 6.0 - self.max_section_seconds = 120.0 - self.energy_smoothing_window = 2.0 - self.boundary_sensitivity = 0.65 - self.min_energy_diff_for_boundary = 0.08 - self.ambiguity_threshold = 0.25 - - def _compute_segment_features(self, rms: np.ndarray, onset: np.ndarray, - centroid: np.ndarray, start_frame: int, - end_frame: int, rms_global_max: float = None) -> Dict[str, float]: - """Compute normalized features for a segment.""" - rms_seg = rms[start_frame:end_frame] - onset_seg = onset[start_frame:end_frame] - centroid_seg = centroid[start_frame:end_frame] - - if len(rms_seg) == 0: - return {'energy': 0.0, 'onset_density': 0.0, 'brightness': 0.0, 'flux': 0.0, - 'energy_stability': 1.0, 'onset_variability': 0.0} - - rms_global_max = rms_global_max if rms_global_max is not None else float(np.max(rms)) - rms_global_max = max(rms_global_max, 0.001) - - energy = float(np.mean(rms_seg)) - onset_density = float(np.mean(onset_seg)) / 5.0 - brightness = float(np.mean(centroid_seg)) / 10000.0 - - if len(centroid_seg) > 1: - flux = float(np.mean(np.abs(np.diff(centroid_seg)))) / 2000.0 - else: - flux = 0.0 - - energy_stability = 1.0 - if len(rms_seg) > 1: - energy_cv = float(np.std(rms_seg)) / max(float(np.mean(rms_seg)), 0.001) - energy_stability = min(1.0, max(0.0, 1.0 - energy_cv * 2.0)) - - onset_variability = 0.0 - if len(onset_seg) > 1: - onset_std = float(np.std(onset_seg)) - onset_mean = max(float(np.mean(onset_seg)), 0.001) - onset_variability = min(1.0, onset_std / onset_mean) - - return { - 'energy': min(1.0, max(0.0, (energy / rms_global_max) * 1.5)), - 'onset_density': min(1.0, max(0.0, onset_density)), - 'brightness': min(1.0, max(0.0, brightness)), - 'flux': min(1.0, max(0.0, flux)), - 'energy_stability': round(energy_stability, 3), - 'onset_variability': round(onset_variability, 3) - } - - def _compute_richer_section_features( - self, - y: np.ndarray, - sr: int, - rms: np.ndarray, - onset_env: np.ndarray, - centroid: np.ndarray, - start_time: float, - end_time: float, - hop_length: int = 512, - n_fft: int = 2048 - ) -> Dict[str, float]: - """ - Compute richer per-section features for better reference matching. - - Returns energy_mean, energy_peak, energy_slope, spectral_centroid_mean, - spectral_centroid_std, onset_rate, low_energy_ratio, high_energy_ratio. - """ - duration = end_time - start_time - if duration < 1.0: - return { - 'energy_mean': 0.0, - 'energy_peak': 0.0, - 'energy_slope': 0.0, - 'spectral_centroid_mean': 0.0, - 'spectral_centroid_std': 0.0, - 'onset_rate': 0.0, - 'low_energy_ratio': 0.0, - 'high_energy_ratio': 0.0, - } - - frames_per_second = sr / hop_length - start_frame = int(start_time * frames_per_second) - end_frame = int(end_time * frames_per_second) - - start_frame = max(0, min(start_frame, len(rms) - 1)) - end_frame = max(start_frame + 1, min(end_frame, len(rms))) - - section_rms = rms[start_frame:end_frame] - section_onset = onset_env[start_frame:end_frame] - section_centroid = centroid[start_frame:end_frame] - - if len(section_rms) == 0: - return { - 'energy_mean': 0.0, - 'energy_peak': 0.0, - 'energy_slope': 0.0, - 'spectral_centroid_mean': 0.0, - 'spectral_centroid_std': 0.0, - 'onset_rate': 0.0, - 'low_energy_ratio': 0.0, - 'high_energy_ratio': 0.0, - } - - # Energy metrics (normalized 0-1) - rms_max_global = float(np.max(rms)) if len(rms) > 0 else 0.01 - energy_mean = float(np.mean(section_rms)) - energy_peak = float(np.max(section_rms)) - energy_mean_norm = min(1.0, (energy_mean / max(rms_max_global, 0.001)) * 2.0) - energy_peak_norm = min(1.0, (energy_peak / max(rms_max_global, 0.001)) * 1.5) - - # Energy slope (trend within section) - if len(section_rms) > 2: - x = np.arange(len(section_rms)) - slope, _ = np.polyfit(x, section_rms, 1) - energy_slope_norm = float(np.clip(slope * 100, -1.0, 1.0)) - else: - energy_slope_norm = 0.0 - - # Spectral centroid metrics - centroid_mean = float(np.mean(section_centroid)) - centroid_std = float(np.std(section_centroid)) if len(section_centroid) > 1 else 0.0 - centroid_mean_norm = min(1.0, centroid_mean / 10000.0) - centroid_std_norm = min(1.0, centroid_std / 6000.0) - - # Onset rate (onsets per second) - onset_threshold = float(np.mean(section_onset)) + float(np.std(section_onset)) * 0.5 - onset_count = int(np.sum(section_onset > onset_threshold)) - onset_rate = onset_count / max(duration, 0.1) - onset_rate_norm = min(1.0, onset_rate / 20.0) - - # Low and high energy ratios (STFT-based frequency analysis) - start_sample = int(start_time * sr) - end_sample = int(end_time * sr) - start_sample = max(0, min(start_sample, len(y) - 1)) - end_sample = max(start_sample + 512, min(end_sample, len(y))) - - try: - S = np.abs(librosa.stft(y[start_sample:end_sample], n_fft=n_fft)) - freqs = librosa.fft_frequencies(sr=sr, n_fft=n_fft) - total_energy = float(np.sum(S ** 2)) + 1e-10 - - low_mask = freqs < 300 - high_mask = freqs > 4000 - - low_energy = float(np.sum(S[low_mask, :] ** 2)) - high_energy = float(np.sum(S[high_mask, :] ** 2)) - - low_energy_ratio = min(1.0, low_energy / total_energy) - high_energy_ratio = min(1.0, high_energy / total_energy) - except Exception: - low_energy_ratio = 0.0 - high_energy_ratio = 0.0 - - return { - 'energy_mean': round(energy_mean_norm, 4), - 'energy_peak': round(energy_peak_norm, 4), - 'energy_slope': round(energy_slope_norm, 4), - 'spectral_centroid_mean': round(centroid_mean_norm, 4), - 'spectral_centroid_std': round(centroid_std_norm, 4), - 'onset_rate': round(onset_rate_norm, 4), - 'low_energy_ratio': round(low_energy_ratio, 4), - 'high_energy_ratio': round(high_energy_ratio, 4), - } - - def _compute_section_kind_confidence( - self, - kind: str, - features: Dict[str, float], - position_ratio: float, - prev_features: Optional[Dict[str, float]] - ) -> Tuple[float, List[str]]: - """ - Compute confidence score for section kind classification. - - Returns (confidence, alternatives) where: - - confidence is 0.0-1.0 with clear semantic thresholds: - - 0.75+: high confidence (section type is clear) - - 0.55-0.75: medium confidence (likely correct but could be alternative) - - 0.35-0.55: low confidence (ambiguous, check alternatives) - - <0.35: very low confidence (section may be misclassified) - - alternatives is list of 1-2 other plausible kinds - - Enhanced with energy trend, onset variability, positional context, and feature matching. - """ - energy = features.get('energy', 0.5) - onset_density = features.get('onset_density', 0.5) - onset_var = features.get('onset_variability', 0.0) - stability = features.get('energy_stability', 1.0) - brightness = features.get('brightness', 0.5) - - energy_mean = features.get('energy_mean', energy) - onset_rate = features.get('onset_rate', onset_density) - - energy_trend = features.get('energy_trend', 0.0) - if energy_trend == 0.0 and prev_features: - prev_energy = prev_features.get('energy', energy) - energy_trend = energy - prev_energy - - profile = SECTION_PROFILES.get(kind, {}) - confidence = 0.35 - alternatives = [] - - prev_energy = prev_features.get('energy', energy) if prev_features else energy - energy_rising = energy_trend > 0.08 - energy_falling = energy_trend < -0.08 - - def _match_range(value: float, range_tuple: Tuple[float, float]) -> float: - if not range_tuple: - return 0.5 - lo, hi = range_tuple - if lo <= value <= hi: - center = (lo + hi) / 2 - spread = (hi - lo) / 2 - dist_from_center = abs(value - center) - return 1.0 - (dist_from_center / (spread * 2 + 0.01)) - elif value < lo: - return max(0.0, 1.0 - (lo - value) * 2) - else: - return max(0.0, 1.0 - (value - hi) * 2) - - energy_match = _match_range(energy_mean, profile.get('energy_range', (0.0, 1.0))) - onset_match = _match_range(onset_rate, profile.get('onset_density', (0.0, 1.0))) - brightness_match = _match_range(brightness, profile.get('spectral_brightness', (0.0, 1.0))) - stability_match = _match_range(stability, profile.get('energy_stability', (0.0, 1.0))) - - pos_range = profile.get('typical_position', (0.0, 1.0)) - position_match = _match_range(position_ratio, pos_range) - - base_feature_score = (energy_match * 0.35 + onset_match * 0.25 + brightness_match * 0.15 + stability_match * 0.15 + position_match * 0.10) - - if kind == 'intro': - if prev_features is None: - confidence = 0.85 + base_feature_score * 0.15 - elif position_ratio < 0.12 and energy_mean < 0.32: - confidence = 0.78 + base_feature_score * 0.18 - elif position_ratio < 0.18 and energy_mean < 0.40: - confidence = 0.62 + base_feature_score * 0.15 - elif position_ratio < 0.22 and energy_mean < 0.45: - confidence = 0.48 + base_feature_score * 0.12 - else: - confidence = 0.30 + base_feature_score * 0.10 - if energy_mean > 0.55: - confidence -= 0.18 - if energy_rising and position_ratio > 0.1: - confidence -= 0.10 - alternatives = ['verse', 'break', 'build'] - - elif kind == 'outro': - if position_ratio > 0.90: - confidence = 0.88 + base_feature_score * 0.12 - elif position_ratio > 0.85 and energy_mean < 0.35: - confidence = 0.75 + base_feature_score * 0.15 - elif position_ratio > 0.80 and energy_mean < 0.42: - confidence = 0.58 + base_feature_score * 0.12 - else: - confidence = 0.32 + base_feature_score * 0.08 - if energy_falling: - confidence += 0.12 - if energy_mean > 0.55: - confidence -= 0.12 - alternatives = ['break', 'verse', 'build'] - - elif kind == 'drop': - if energy_mean > 0.72 and onset_rate > 0.48 and stability > 0.55: - confidence = 0.92 + (energy_mean - 0.72) * 0.3 - elif energy_mean > 0.62 and onset_rate > 0.40: - confidence = 0.78 + base_feature_score * 0.15 - elif energy_mean > 0.52 and onset_rate > 0.35: - confidence = 0.55 + base_feature_score * 0.12 - else: - confidence = 0.30 + base_feature_score * 0.08 - if 0.25 < position_ratio < 0.75: - confidence += 0.05 - if position_ratio < 0.18: - confidence -= 0.15 - alternatives = ['build', 'verse'] - - elif kind == 'build': - slope_range = profile.get('energy_slope', (0.0, 1.0)) - slope_match = _match_range(energy_trend, slope_range) if slope_range else 0.5 - - if energy_rising and 0.40 < energy_mean < 0.72: - confidence = 0.82 + slope_match * 0.15 - if onset_var > 0.25: - confidence = min(confidence + 0.08, 0.95) - elif energy_rising and 0.35 < energy_mean < 0.78: - confidence = 0.62 + slope_match * 0.18 - elif 0.35 < energy_mean < 0.72 and not energy_falling: - confidence = 0.45 + base_feature_score * 0.15 - else: - confidence = 0.28 + base_feature_score * 0.08 - if position_ratio < 0.12 or position_ratio > 0.88: - confidence -= 0.12 - alternatives = ['drop', 'verse', 'break'] - - elif kind == 'break': - if energy_mean < 0.35 and onset_rate < 0.30 and stability > 0.50: - confidence = 0.85 + base_feature_score * 0.12 - elif energy_mean < 0.42 and onset_rate < 0.38: - confidence = 0.65 + base_feature_score * 0.10 - elif energy_mean < 0.48 and onset_rate < 0.45: - confidence = 0.42 + base_feature_score * 0.08 - else: - confidence = 0.28 + base_feature_score * 0.06 - if 0.25 < position_ratio < 0.75: - confidence += 0.06 - if brightness > 0.55: - confidence -= 0.06 - alternatives = ['intro', 'outro', 'verse'] - - elif kind == 'verse': - if 0.25 < energy_mean < 0.58 and 0.25 < onset_rate < 0.65 and stability > 0.45: - confidence = 0.72 + base_feature_score * 0.15 - elif 0.28 < energy_mean < 0.55: - confidence = 0.52 + base_feature_score * 0.12 - else: - confidence = 0.35 + base_feature_score * 0.08 - if 0.15 < position_ratio < 0.75: - confidence += 0.05 - alternatives = ['build', 'drop', 'break'] - - else: - confidence = 0.40 + base_feature_score * 0.10 - alternatives = ['verse', 'drop'] - - total_sections = features.get('total_sections', 4) - if total_sections <= 2: - confidence = min(confidence * 0.90, 0.95) - elif total_sections >= 8: - pass - - confidence = max(0.15, min(0.98, confidence)) - - return round(confidence, 3), alternatives - - def _section_character_bonus( - self, - role: str, - candidate_analysis: Dict[str, Any], - section_features: Dict[str, Any] - ) -> float: - """ - Compute a character bonus for matching a candidate sample to a section. - - Returns a multiplier (1.0 = no change, max ~1.25) based on how well - the candidate's features match the section's acoustic character. - """ - if not section_features: - return 1.0 - - bonus = 1.0 - - onset_rate = float(section_features.get('onset_rate', 0.5)) - low_energy_ratio = float(section_features.get('low_energy_ratio', 0.0)) - high_energy_ratio = float(section_features.get('high_energy_ratio', 0.0)) - energy_slope = float(section_features.get('energy_slope', 0.0)) - energy_mean = float(section_features.get('energy_mean', 0.5)) - - candidate_centroid = float(candidate_analysis.get('spectral_centroid', 0.0) or 0.0) - candidate_onset = float(candidate_analysis.get('onset_mean', 0.0) or 0.0) - - role_lower = role.lower() - - # High onset rate section + high onset density candidate = bonus - if onset_rate > 0.4: - candidate_onset_norm = min(1.0, candidate_onset / 5.0) - if role_lower in {'hat', 'top_loop', 'perc_loop', 'perc'}: - if candidate_onset_norm > 0.6: - bonus = max(bonus, 1.0 + (candidate_onset_norm - 0.5) * 0.25) - - # High low-energy ratio + bass role = bonus - if low_energy_ratio > 0.4: - candidate_low_centroid = max(0.0, 1.0 - candidate_centroid / 3000.0) - if role_lower in {'bass_loop', 'sub_bass', 'bass'}: - if candidate_low_centroid > 0.5: - bonus = max(bonus, 1.0 + candidate_low_centroid * 0.15) - - # High high-energy ratio + hat/top role = bonus - if high_energy_ratio > 0.3: - candidate_high_centroid = min(1.0, candidate_centroid / 10000.0) - if role_lower in {'hat', 'top_loop', 'crash_fx'}: - if candidate_high_centroid > 0.5: - bonus = max(bonus, 1.0 + candidate_high_centroid * 0.12) - - # Building section (positive slope) + snare_roll/fill_fx = bonus - if energy_slope > 0.1: - if role_lower in {'snare_roll', 'fill_fx', 'riser'}: - bonus = max(bonus, 1.0 + energy_slope * 0.25) - - # Low energy section + atmos_fx = bonus - if energy_mean < 0.3: - if role_lower in {'atmos_fx', 'atmos', 'pad'}: - bonus = max(bonus, 1.0 + (0.3 - energy_mean) * 0.4) - - return min(1.25, max(1.0, round(bonus, 3))) - - def _get_role_section_features( - self, role: str, reference_sections: List[Dict[str, Any]], - role_segments: List[Dict[str, Any]] - ) -> Dict[str, Any]: - """Get the most relevant section features for a given role.""" - if not reference_sections: - return {} - - role_lower = role.lower() - - preferred_kinds: Dict[str, List[str]] = { - 'kick': ['drop', 'build'], - 'snare': ['drop', 'build'], - 'hat': ['drop', 'verse'], - 'bass_loop': ['drop', 'build'], - 'sub_bass': ['drop', 'build'], - 'top_loop': ['drop', 'verse'], - 'perc_loop': ['drop', 'build'], - 'synth_loop': ['drop', 'verse'], - 'vocal_loop': ['drop', 'verse'], - 'vocal_shot': ['drop', 'verse'], - 'snare_roll': ['build', 'intro'], - 'fill_fx': ['build', 'break'], - 'riser': ['build', 'intro'], - 'crash_fx': ['drop', 'intro', 'outro'], - 'atmos_fx': ['break', 'intro', 'outro'], - 'atmos': ['break', 'intro', 'outro'], - 'pad': ['break', 'intro'], - } - - kinds = preferred_kinds.get(role_lower, ['drop']) - - for section in reference_sections: - kind = str(section.get('kind', 'drop')).lower() - if kind in kinds: - return section.get('features', {}) - - if reference_sections: - for section in reference_sections: - if section.get('kind', 'drop') == 'drop': - return section.get('features', {}) - return reference_sections[0].get('features', {}) - - return {} - - def _find_boundary_peaks(self, energy_diff: np.ndarray, onset_peaks: np.ndarray, - threshold: float, min_gap_frames: int) -> List[int]: - """Find section boundary peaks combining energy changes and onset peaks with improved detection.""" - if len(energy_diff) == 0: - return [] - - threshold_val = float(threshold) - - energy_percentile = float(np.percentile(energy_diff, 75)) if len(energy_diff) > 10 else threshold_val - onset_percentile = float(np.percentile(onset_peaks, 55)) - - candidates = [] - for i in range(len(energy_diff)): - energy_score = float(energy_diff[i]) - onset_score = float(onset_peaks[i]) - - combined_score = energy_score * 0.6 + onset_score * 0.4 - - if energy_score > threshold_val and onset_score > onset_percentile * 0.8: - candidates.append((i, combined_score, 'both')) - elif energy_score > energy_percentile and onset_score > onset_percentile * 0.5: - candidates.append((i, combined_score * 0.7, 'energy')) - elif onset_score > float(np.percentile(onset_peaks, 85)) and energy_score > threshold_val * 0.5: - candidates.append((i, combined_score * 0.6, 'onset')) - - if not candidates: - for i in range(len(energy_diff)): - if float(energy_diff[i]) > threshold_val * 0.7: - candidates.append((i, float(energy_diff[i]), 'fallback')) - - candidates.sort(key=lambda x: x[1], reverse=True) - - boundaries = [] - for idx, score, method in candidates: - is_valid = True - for existing in boundaries: - if abs(idx - existing) < min_gap_frames: - is_valid = False - break - if is_valid: - boundaries.append(idx) - - boundaries.sort() - return boundaries - - def _validate_section_progression(self, sections: List[Dict[str, Any]], - duration: float, tempo: float) -> List[Dict[str, Any]]: - """Validate and fix section progression for musical coherence.""" - if not sections: - return [{'kind': 'drop', 'start': 0.0, 'end': duration, - 'duration': duration, 'bars': max(8, int(duration * tempo / 60 / 4)), - 'kind_confidence': 0.3, 'features': {'energy': 0.5}}] - - beats_per_second = tempo / 60.0 - seconds_per_bar = 4.0 / beats_per_second if beats_per_second > 0 else 2.0 - - result = [] - for i, section in enumerate(sections): - kind = section.get('kind', 'drop') - start = section.get('start', 0.0) - end = section.get('end', duration) - sec_duration = end - start - - estimated_bars = max(4, int(round(sec_duration / seconds_per_bar))) - if estimated_bars > self.max_section_bars: - kind = 'drop' if section.get('features', {}).get('energy', 0.5) > 0.6 else 'break' - if estimated_bars < self.min_section_bars and i > 0: - prev_section = result[-1] if result else None - if prev_section and prev_section.get('kind') == kind: - prev_section['end'] = end - prev_section['duration'] = end - prev_section['start'] - prev_section['bars'] += estimated_bars - continue - - section['bars'] = estimated_bars - section['beats'] = estimated_bars * 4 - result.append(section) - - for i, section in enumerate(result): - section['section_index'] = i - section['total_sections'] = len(result) - - return result - - def _compute_energy_transitions(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Compute energy transition direction between sections.""" - if len(sections) < 2: - return sections - - for i, section in enumerate(sections): - next_section = sections[i + 1] if i < len(sections) - 1 else None - prev_section = sections[i - 1] if i > 0 else None - - current_energy = section.get('features', {}).get('energy', 0.5) - next_energy = next_section.get('features', {}).get('energy', current_energy) if next_section else current_energy - prev_energy = prev_section.get('features', {}).get('energy', current_energy) if prev_section else current_energy - - energy_diff_next = next_energy - current_energy - energy_diff_prev = current_energy - prev_energy - - if energy_diff_next > 0.15: - section['energy_transition'] = 'rising' - elif energy_diff_next < -0.15: - section['energy_transition'] = 'falling' - else: - section['energy_transition'] = 'stable' - - section['energy_delta_next'] = round(energy_diff_next, 3) - section['energy_delta_prev'] = round(energy_diff_prev, 3) - - return sections - - def detect_sections(self, rms: np.ndarray, onset: np.ndarray, - centroid: np.ndarray, duration: float, - min_section_seconds: float = 8.0) -> List[Dict[str, Any]]: - """Detect sections from audio features with improved segmentation and edge case handling.""" - if len(rms) == 0 or duration < min_section_seconds * 1.5: - default_bars = max(8, int(duration * 128 / 60 / 4)) if duration > 0 else 8 - return [{'kind': 'drop', 'start': 0.0, 'end': duration, 'bars': default_bars, - 'duration': duration, 'kind_confidence': 0.35, - 'confidence_level': 'low', - 'features': {'energy': 0.5, 'onset_density': 0.5}, - 'detection_method': 'fallback_short_track'}] - - hop_time = self.hop_length / self.sr - frames_per_section = max(1, int(min_section_seconds / hop_time)) - - rms_global_max = float(np.max(rms)) if len(rms) > 0 else 0.01 - kernel_size = min(len(rms), max(1, int(self.energy_smoothing_window / hop_time))) - - if kernel_size > 1: - smoothed_rms = np.convolve(rms, np.ones(kernel_size) / kernel_size, mode='same') - else: - smoothed_rms = rms - - if len(smoothed_rms) > 1: - energy_diff = np.abs(np.diff(smoothed_rms)) - if len(energy_diff) > kernel_size: - energy_diff = np.convolve(energy_diff, np.ones(kernel_size) / kernel_size, mode='same') - else: - energy_diff = np.zeros(1) - - onset_binary = (onset > np.percentile(onset, 65)).astype(float) - onset_peaks = np.convolve(onset_binary, np.ones(kernel_size) / kernel_size, mode='same') - - base_threshold = max(float(np.percentile(energy_diff, 65)), 0.001) if len(energy_diff) > 10 else 0.001 - threshold = base_threshold * self.boundary_sensitivity - - primary_boundaries = self._find_boundary_peaks(energy_diff, onset_peaks, float(threshold), frames_per_section) - - secondary_threshold = float(threshold) * 0.55 - secondary_boundaries = self._find_boundary_peaks(energy_diff, onset_peaks, secondary_threshold, frames_per_section // 2) - - all_boundaries = sorted(set([0] + primary_boundaries + secondary_boundaries + [len(rms) - 1])) - consolidated_boundaries = [all_boundaries[0]] - for boundary in all_boundaries[1:]: - min_gap = frames_per_section * 0.4 - if boundary - consolidated_boundaries[-1] >= min_gap: - consolidated_boundaries.append(boundary) - - if len(consolidated_boundaries) < 3 and duration > min_section_seconds * 2: - _ = smoothed_rms - n_segments = max(3, min(6, int(duration / min_section_seconds))) - segment_boundaries = [0] - for i in range(1, n_segments): - target_frame = int(i * len(rms) / n_segments) - search_range = max(1, int(len(rms) / (n_segments * 2))) - best_frame = target_frame - best_diff = float('inf') - for j in range(max(0, target_frame - search_range), min(len(energy_diff), target_frame + search_range)): - if float(energy_diff[j]) > best_diff * 0.8: - best_diff = float(energy_diff[j]) - best_frame = j - segment_boundaries.append(best_frame) - segment_boundaries.append(len(rms) - 1) - consolidated_boundaries = sorted(set(consolidated_boundaries + segment_boundaries)) - - sections = [] - prev_features = None - prev_energy_trend = None - - for i in range(len(consolidated_boundaries) - 1): - start_frame = consolidated_boundaries[i] - end_frame = consolidated_boundaries[i + 1] - - if end_frame <= start_frame: - continue - - start_time = start_frame * hop_time - end_time = end_frame * hop_time - segment_duration = end_time - start_time - - min_duration = min_section_seconds * 0.2 - if segment_duration < min_duration: - if sections: - sections[-1]['end'] = end_time - sections[-1]['duration'] = end_time - sections[-1]['start'] - sections[-1]['merged_short'] = True - continue - - max_duration = self.max_section_seconds - if segment_duration > max_duration: - mid_frame = (start_frame + end_frame) // 2 - consolidated_boundaries.insert(i + 1, mid_frame) - end_frame = mid_frame - end_time = end_frame * hop_time - segment_duration = end_time - start_time - - features = self._compute_segment_features( - rms, onset, centroid, start_frame, end_frame, rms_global_max - ) - - energy = features.get('energy', 0.5) - if prev_features: - energy_trend = energy - prev_features.get('energy', 0.5) - else: - energy_trend = 0.0 - features['energy_trend'] = round(energy_trend, 3) - - position_ratio = start_time / duration if duration > 0 else 0.0 - positional_weight = self._compute_positional_weight(position_ratio, len(consolidated_boundaries) - 1, i) - - kind = self._classify_segment_v2( - features, position_ratio, prev_features, energy_trend, prev_energy_trend - ) - - estimated_bars = max(4, int(round(segment_duration * 128 / 60 / 4))) - - sections.append({ - 'kind': kind, - 'start': round(start_time, 3), - 'end': round(end_time, 3), - 'duration': round(segment_duration, 3), - 'bars': estimated_bars, - 'features': features, - 'positional_weight': positional_weight, - }) - - prev_features = features - prev_energy_trend = energy_trend - - merged = [] - for section in sections: - if merged and merged[-1]['kind'] == section['kind'] and section['duration'] < min_section_seconds * 0.6: - merged[-1]['end'] = section['end'] - merged[-1]['duration'] = round(section['end'] - merged[-1]['start'], 3) - merged[-1]['bars'] += section.get('bars', 4) - merged_features = merged[-1].get('features', {}) - new_features = section.get('features', {}) - merged_features['energy'] = (merged_features.get('energy', 0.5) + new_features.get('energy', 0.5)) / 2 - merged[-1]['merged_with_next'] = True - else: - merged.append(section) - - merged = self._validate_section_progression(merged, duration, 128.0) - merged = self._compute_energy_transitions(merged) - - merged = self._add_confidence_levels(merged) - - if len(merged) < 2 and duration > min_section_seconds * 2: - merged = self._create_fallback_sections(duration, 128.0, rms, onset) - - return merged - - def _add_confidence_levels(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Add human-readable confidence levels to sections.""" - for section in sections: - confidence = section.get('kind_confidence', 0.5) - if confidence >= SECTION_CONFIDENCE_THRESHOLDS['high']: - section['confidence_level'] = 'high' - elif confidence >= SECTION_CONFIDENCE_THRESHOLDS['medium']: - section['confidence_level'] = 'medium' - elif confidence >= SECTION_CONFIDENCE_THRESHOLDS['low']: - section['confidence_level'] = 'low' - else: - section['confidence_level'] = 'ambiguous' - return sections - - def _create_fallback_sections(self, duration: float, tempo: float, - rms: np.ndarray, onset: np.ndarray) -> List[Dict[str, Any]]: - """Create fallback sections when detection fails.""" - sections = [] - beats_per_second = tempo / 60.0 - seconds_per_bar = 4.0 / beats_per_second if beats_per_second > 0 else 2.0 - - total_bars = max(16, int(duration / seconds_per_bar)) - - if duration < 60: - sections = [ - {'kind': 'intro', 'start': 0.0, 'end': duration * 0.25, - 'duration': duration * 0.25, 'bars': max(4, int(total_bars * 0.25)), - 'kind_confidence': 0.35, 'confidence_level': 'low', - 'features': {'energy': 0.3}, 'detection_method': 'fallback'}, - {'kind': 'drop', 'start': duration * 0.25, 'end': duration * 0.75, - 'duration': duration * 0.5, 'bars': max(8, int(total_bars * 0.5)), - 'kind_confidence': 0.35, 'confidence_level': 'low', - 'features': {'energy': 0.6}, 'detection_method': 'fallback'}, - {'kind': 'outro', 'start': duration * 0.75, 'end': duration, - 'duration': duration * 0.25, 'bars': max(4, int(total_bars * 0.25)), - 'kind_confidence': 0.35, 'confidence_level': 'low', - 'features': {'energy': 0.35}, 'detection_method': 'fallback'}, - ] - else: - n_sections = min(5, max(3, int(duration / 30))) - section_duration = duration / n_sections - - energy_profile = [] - if len(rms) > n_sections: - segment_size = len(rms) // n_sections - for i in range(n_sections): - segment_rms = rms[i * segment_size:(i + 1) * segment_size] - energy_profile.append(float(np.mean(segment_rms)) if len(segment_rms) > 0 else 0.5) - max_energy = max(energy_profile) if energy_profile else 0.5 - energy_profile = [e / max_energy for e in energy_profile] - else: - energy_profile = [0.3, 0.5, 0.7, 0.6, 0.4][:n_sections] - - kinds = ['intro', 'verse', 'build', 'drop', 'outro'] - for i in range(n_sections): - kind = kinds[i] if i < len(kinds) else 'verse' - if i == n_sections - 1: - kind = 'outro' - elif i == 0: - kind = 'intro' - elif i == n_sections - 2: - kind = 'drop' - elif energy_profile[i] > 0.6 and i > 0 and i < n_sections - 1: - kind = 'drop' - - start = i * section_duration - end = (i + 1) * section_duration if i < n_sections - 1 else duration - - sections.append({ - 'kind': kind, - 'start': round(start, 3), - 'end': round(end, 3), - 'duration': round(end - start, 3), - 'bars': max(4, int((end - start) / seconds_per_bar)), - 'kind_confidence': 0.30, - 'confidence_level': 'low', - 'features': {'energy': energy_profile[i] if i < len(energy_profile) else 0.5}, - 'detection_method': 'fallback_energy_profile', - }) - - return sections - - def _compute_positional_weight(self, position_ratio: float, total_sections: int, - section_index: int) -> float: - """Compute positional weight for section classification confidence.""" - if total_sections <= 1: - return 1.0 - - if position_ratio < 0.15: - return 1.2 - elif position_ratio > 0.85: - return 1.2 - elif 0.35 < position_ratio < 0.65: - return 0.9 - else: - return 1.0 - - def _classify_segment_v2(self, features: Dict[str, float], position_ratio: float, - prev_features: Optional[Dict[str, float]], - energy_trend: float, prev_energy_trend: Optional[float]) -> str: - """Classify segment with improved energy trend and context awareness.""" - energy = features.get('energy', 0.5) - onset = features.get('onset_density', 0.5) - brightness = features.get('brightness', 0.5) - stability = features.get('energy_stability', 1.0) - onset_var = features.get('onset_variability', 0.0) - - is_rising = energy_trend > 0.08 or (prev_energy_trend is not None and prev_energy_trend > 0.05 and energy_trend >= 0) - is_falling = energy_trend < -0.08 or (prev_energy_trend is not None and prev_energy_trend < -0.05) - - is_strong_rise = energy_trend > 0.15 - _ = energy_trend < -0.15 - - scores = {} - - if position_ratio < 0.18: - intro_energy_match = max(0, 0.5 - abs(energy - 0.22)) - intro_onset_match = max(0, 0.4 - abs(onset - 0.22)) - intro_pos_bonus = 0.65 * (0.18 - position_ratio) - intro_stability_bonus = 0.15 if stability > 0.5 else 0 - scores['intro'] = intro_energy_match + intro_onset_match + intro_pos_bonus + intro_stability_bonus - else: - scores['intro'] = -0.5 - - if position_ratio > 0.80: - outro_energy_match = max(0, 0.5 - abs(energy - 0.22)) - outro_onset_match = max(0, 0.4 - abs(onset - 0.22)) - outro_pos_bonus = 0.55 * (position_ratio - 0.80) - outro_falling_bonus = 0.25 if is_falling else (0.10 if not is_rising else -0.15) - scores['outro'] = outro_energy_match + outro_onset_match + outro_pos_bonus + outro_falling_bonus - else: - scores['outro'] = -0.2 - - if is_strong_rise and 0.38 < energy < 0.75: - scores['build'] = 0.85 + (abs(energy_trend) * 1.5) + (onset * 0.25) - elif is_rising and 0.35 < energy < 0.78: - scores['build'] = 0.55 + (abs(energy_trend) * 2.0) + (onset * 0.15) - elif 0.35 < energy < 0.72 and onset > 0.45 and position_ratio < 0.75: - scores['build'] = 0.38 + (onset * 0.25) - elif 0.38 < energy < 0.65 and onset_var > 0.2: - scores['build'] = 0.32 + (onset_var * 0.3) - else: - scores['build'] = max(0, 0.15 - abs(energy_trend) * 2) if energy_trend < 0.05 else 0.08 - - if energy > 0.68 and onset > 0.48 and stability > 0.55: - brightness_bonus = 0.12 if brightness > 0.5 else 0 - scores['drop'] = (energy - 0.50) * 1.4 + (onset - 0.40) * 0.7 + brightness_bonus - elif energy > 0.60 and onset > 0.42: - scores['drop'] = (energy - 0.50) * 1.1 + onset * 0.45 - elif energy > 0.52: - scores['drop'] = 0.35 + (energy - 0.52) * 1.5 - else: - scores['drop'] = max(-0.3, (energy - 0.45) * 2) - - if energy < 0.40 and onset < 0.32 and stability > 0.45: - scores['break'] = 0.75 + (0.40 - energy) * 0.55 + (0.32 - onset) * 0.45 - elif energy < 0.48 and onset < 0.38 and not is_rising: - scores['break'] = 0.45 + (0.48 - energy) * 0.35 + (0.38 - onset) * 0.25 - elif energy < 0.45 and brightness < 0.45: - scores['break'] = 0.35 + (0.45 - energy) * 0.3 - else: - scores['break'] = max(0, 0.08 - abs(energy - 0.35) - abs(onset - 0.32)) - - if 0.22 < energy < 0.60 and 0.22 < onset < 0.68 and stability > 0.40: - scores['verse'] = 0.55 - abs(energy - 0.42) * 1.5 - abs(onset - 0.42) * 1.2 - elif 0.28 < energy < 0.52 and not is_rising and not is_falling: - scores['verse'] = 0.38 - abs(energy - 0.40) * 1.0 - elif 0.25 < energy < 0.55: - scores['verse'] = 0.25 - else: - scores['verse'] = 0.12 - - if not scores: - return 'drop' - - best_kind, best_score = max(scores.items(), key=lambda x: x[1]) - - if best_score < 0.10: - if energy > 0.52: - return 'drop' - elif position_ratio < 0.18: - return 'intro' - elif position_ratio > 0.82: - return 'outro' - elif energy < 0.42: - return 'break' - elif is_rising: - return 'build' - else: - return 'verse' - - second_best = sorted(scores.items(), key=lambda x: x[1], reverse=True) - if len(second_best) > 1: - score_gap = second_best[0][1] - second_best[1][1] - if score_gap < 0.12: - if second_best[0][0] == 'drop' and second_best[1][0] == 'build': - if is_rising: - return 'build' - - return best_kind - - -def generate_segment_rag_summary(report: Dict[str, Any], - library_dir: Path) -> Dict[str, Any]: - """ - Genera resumen enriquecido del indexado. - - Incluye: - - Estadisticas basicas del report - - Coverage por rol - - Segmentos por archivo (avg, min, max) - - Tiempo de procesamiento estimado - - Salud del cache - """ - manifest = report.get('manifest', []) - - # Calcular estadisticas - segment_counts = [m.get('segments', 0) for m in manifest] - - # Coverage por rol - role_segments: Dict[str, int] = defaultdict(int) - for m in manifest: - for role in m.get('roles', []): - role_segments[role] += m.get('segments', 0) - - # Cache size - cache_dir = library_dir / ".segment_rag" - cache_size_bytes = sum(f.stat().st_size for f in cache_dir.glob("*.json.gz")) if cache_dir.exists() else 0 - - return { - **report, # Incluir todos los campos originales - - # Estadisticas agregadas - "summary_stats": { - "avg_segments_per_file": sum(segment_counts) / len(segment_counts) if segment_counts else 0, - "min_segments": min(segment_counts) if segment_counts else 0, - "max_segments": max(segment_counts) if segment_counts else 0, - "total_files_indexed": len(manifest), - }, - - # Coverage por rol - "role_coverage": dict(role_segments), - - # Cache info - "cache_info": { - "cache_dir": str(cache_dir), - "cache_size_bytes": cache_size_bytes, - "cache_size_mb": round(cache_size_bytes / (1024 * 1024), 2), - }, - - # Timestamp - "generated_at": time.time(), - "generated_at_iso": time.strftime('%Y-%m-%dT%H:%M:%S'), - } - - -class ReferenceAudioListener: - # Improved role patterns with more comprehensive matching - ROLE_PATTERNS = { - 'kick': ['**/*Kick*.wav', '**/*kick*.wav', '**/*KICK*.wav', '**/*Kick_*.wav', '**/*_Kick*.wav', '**/*BD*.wav', '**/*bd*.wav', '**/*bd_*.wav'], - 'snare': ['**/*Clap*Hit*.wav', '**/*Snare*.wav', '**/*snare*.wav', '**/*Clap*.wav', '**/*clap*.wav', - '**/*SNARE*.wav', '**/*CLAP*.wav', '**/*Clap_*.wav', '**/*Snare_*.wav', '**/*SD*.wav', '**/*sd*.wav'], - 'hat': ['**/*Closed Hat*.wav', '**/*Hat*.wav', '**/*hat*.wav', '**/*HAT*.wav', '**/*ClosedHat*.wav', - '**/*Open Hat*.wav', '**/*OpenHat*.wav', '**/*cym*.wav', '**/*hihat*.wav', '**/*HiHat*.wav', '**/*HH*.wav', '**/*hh_*.wav'], - 'bass_loop': ['**/*Bass Loop*.wav', '**/*Bass_Loop*.wav', '**/*bass_loop*.wav', '**/*BassLoop*.wav', - '**/*BASS LOOP*.wav', '**/*Sub*Bass*.wav', '**/*Reese*.wav', '**/*808*.wav', '**/bass/*.wav'], - 'perc_loop': ['**/*Percussion Loop*.wav', '**/*Perc_Loop*.wav', '**/*perc_loop*.wav', - '**/*PercLoop*.wav', '**/*Perc*.wav', '**/*perc*.wav', '**/*Conga*.wav', '**/perc/*.wav'], - 'top_loop': ['**/*Top Loops*.wav', '**/*Top Loop*.wav', '**/*Full Drum*.wav', '**/*top_loop*.wav', - '**/*TopLoop*.wav', '**/*Drum Loop*.wav', '**/*DrumLoop*.wav', '**/*FullDrum*.wav', '**/hat/*.wav'], - 'synth_loop': ['**/*Synth Loop*.wav', '**/*Synth_Loop*.wav', '**/*synth_loop*.wav', - '**/*SynthLoop*.wav', '**/*Synth*.wav', '**/*synth*.wav', '**/*Chord*.wav', '**/*Pad*.wav', '**/synth/*.wav'], - 'vocal_loop': ['**/*Vocal Loop*.wav', '**/*Vox*.wav', '**/*vocal_loop*.wav', '**/*VocalLoop*.wav', - '**/*Vocal*.wav', '**/*vocal*.wav', '**/*VOCAL*.wav', '**/*VoxLoop*.wav', '**/*Chopped*.wav', '**/vocal/*.wav'], - 'crash_fx': ['**/*Crash*.wav', '**/*crash*.wav', '**/*CRASH*.wav', '**/*Impact*.wav', '**/*impact*.wav', - '**/*Cymbal*.wav', '**/*cymbal*.wav', '**/fx/*.wav'], - 'fill_fx': ['**/*Fill*.wav', '**/*fill*.wav', '**/*Tom Loop*.wav', '**/*Tom*.wav', '**/*tom*.wav', - '**/*Transition*.wav', '**/*FX*.wav'], - 'snare_roll': ['**/*Snareroll*.wav', '**/*Snare Roll*.wav', '**/*snare_roll*.wav', '**/*SnareRoll*.wav', - '**/*Roll*.wav', '**/*roll*.wav', '**/*Buildup*.wav'], - 'atmos_fx': ['**/*Atmos*.wav', '**/*atmos*.wav', '**/*Drone*.wav', '**/*drone*.wav', '**/*Ambient*.wav', - '**/*Noise*.wav', '**/*noise*.wav', '**/*Texture*.wav', '**/*Pad*.wav', '**/textures/*.wav'], - 'vocal_shot': ['**/*Vocal One Shot*.wav', '**/*Vocal Importante*.wav', '**/*vocal_shot*.wav', - '**/*VocalShot*.wav', '**/*OneShot*.wav', '**/*Shot*.wav', '**/*vocal chop*.wav'], - } - - # Role bus assignments - ROLE_TO_BUS = { - 'kick': 'drums', 'snare': 'drums', 'hat': 'drums', - 'bass_loop': 'bass', - 'perc_loop': 'drums', 'top_loop': 'drums', - 'synth_loop': 'music', - 'vocal_loop': 'vocal', 'vocal_shot': 'vocal', - 'crash_fx': 'fx', 'fill_fx': 'fx', 'snare_roll': 'fx', 'atmos_fx': 'fx', - } - - # Patrones de exclusion fuerte por rol - estos NUNCA deben pasar - ROLE_EXCLUSION_PATTERNS = { - 'kick': [ - 'full drum', 'full_mix', 'fullmix', 'fulldrum', 'full mix','demo', 'song', 'master', 'top loop', 'drum loop', - 'snare roll', 'fill', 'hat loop', 'vocal loop', 'complete kit','full kit', 'mixed', 'stems', 'bounce', 'preview' - ], - 'snare': [ - 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'snare roll', 'snare_roll', 'hat loop', 'kick loop', - 'top loop', 'drum loop', 'bass loop', 'complete kit', 'full kit','mixed', 'stems', 'bounce', 'preview' - ], - 'hat': [ - 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'kick loop', 'snare loop', 'bass loop', 'vocal loop', - 'complete', 'full kit', 'mixed', 'stems', 'bounce', 'preview' - ], - 'bass_loop': [ - 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'top loop', 'vocal loop', 'vocal_loop', 'drum loop', - 'hat loop', 'snare loop', 'perc loop', 'fx loop', 'atmos','complete', 'mixed', 'stems', 'bounce', 'preview' - ], - 'vocal_loop': [ - 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'one shot', 'oneshot', 'hit', 'stab', 'drum loop', - 'bass loop', 'top loop', 'hat loop', 'kick', 'snare','complete', 'mixed', 'stems', 'bounce', 'preview' - ], - 'top_loop': [ - 'bass loop', 'bass_loop', 'vocal loop', 'vocal_loop','demo', 'song', 'master','synth loop', 'pad', 'atmos', 'riser', 'downlifter','complete', 'mixed', 'stems', 'bounce', 'preview' - ], - 'fill_fx': [ - 'kick', 'snare', 'hat', 'clap', 'bass', 'vocal','full mix', 'demo', 'song', 'master', 'loop', 'groove','complete', 'mixed', 'stems', 'bounce', 'preview' - ], - 'snare_roll': [ - 'kick', 'hat', 'clap', 'bass', 'vocal','full mix', 'demo', 'song', 'master', 'atmos', 'pad','complete', 'mixed', 'stems', 'bounce', 'preview' - ], - 'atmos_fx': [ - 'kick', 'snare', 'hat', 'clap', 'bass','full mix', 'demo', 'song', 'master', 'drum loop','complete', 'mixed', 'stems', 'bounce', 'preview' - ], - 'synth_loop': [ - 'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song','master', 'drum loop', 'vocal loop', 'bass loop','complete', 'mixed', 'stems', 'bounce', 'preview' - ], - 'crash_fx': [ - 'full mix', 'demo', 'song', 'master', 'loop','complete', 'mixed', 'stems', 'bounce', 'preview' - ], - 'vocal_shot': [ - 'full mix', 'demo', 'song', 'master', 'loop','complete', 'mixed', 'stems', 'bounce', 'preview' - ], - } - - def __init__(self, library_dir: str, cache_path: Optional[str] = None): - self.library_dir = Path(library_dir) - self.cache_path = Path(cache_path) if cache_path else self.library_dir / ".reference_audio_cache.json" - self.segment_index_dir = self.library_dir / ".segment_rag" - self.segment_index_dir.mkdir(parents=True, exist_ok=True) - self._cache: Dict[str, Any] = self._load_cache() - self.device, self.device_name = self._resolve_device() - self._recent_paths = deque(maxlen=64) # Increased from 48 - self._recent_families = deque(maxlen=32) # Increased from 24 - self._family_usage_count: Dict[str, int] = {} # Track family usage for progressive penalty - self._section_detector = SectionDetector() # New section detector - self.sample_index_path = self.library_dir / ".sample_index.json" - self.vector_store_dir = self.library_dir.parent / "vector_store" - self._sample_index_by_path = self._load_sample_index_metadata() - self._vector_store_meta_by_path, self._vector_store_meta_by_name = self._load_vector_store_metadata() - # DJ-06: Reference directory for auto-discovery - self.reference_dir = self.library_dir.parent / "reference" - self.reference_dir.mkdir(parents=True, exist_ok=True) - - def discover_reference_track(self) -> Optional[Dict[str, Any]]: - """DJ-06: Auto-discover reference tracks from librerias/reference/ directory. - Returns analysis results (BPM, key, energy) from the first found reference file. - """ - audio_extensions = {'.mp3', '.wav', '.aif', '.aiff', '.flac'} - ref_files = [] - try: - for f in self.reference_dir.iterdir(): - if f.is_file() and f.suffix.lower() in audio_extensions: - ref_files.append(f) - except Exception: - pass - - if not ref_files: - return None - - # Analyze the first (or most recent) reference file - ref_file = max(ref_files, key=lambda f: f.stat().st_mtime) - try: - analysis = self.analyze_file(str(ref_file)) - return { - "reference_file": str(ref_file), - "reference_name": ref_file.stem, - "bpm": analysis.get("tempo", 0), - "key": analysis.get("key", ""), - "key_score": analysis.get("key_score", 0), - "energy": analysis.get("rms_mean", 0), - "sections": [ - {"name": s.get("label", ""), "start": s.get("start", 0), "end": s.get("end", 0)} - for s in analysis.get("sections", []) - ], - "duration": analysis.get("duration", 0), - } - except Exception as e: - return {"error": str(e), "reference_file": str(ref_file)} - - def suggest_from_reference(self) -> Dict[str, Any]: - """DJ-06: Get generation suggestions from auto-discovered reference track. - Returns recommended BPM, key, and structure parameters. - """ - ref = self.discover_reference_track() - if ref is None: - return {"available": False, "note": "Place a reference track in librerias/reference/"} - - if "error" in ref: - return {"available": False, "error": ref["error"]} - - return { - "available": True, - "reference_name": ref.get("reference_name", ""), - "recommended_bpm": ref.get("bpm", 126), - "recommended_key": ref.get("key", "Am"), - "reference_duration": ref.get("duration", 0), - "section_count": len(ref.get("sections", [])), - "note": "Use these values with generate_track() for reference-informed generation" - } - - @staticmethod - def _name_contains_any(name: str, tokens: Tuple[str, ...]) -> bool: - return any(token in name for token in tokens) - - @staticmethod - def _name_contains_none(name: str, tokens: Tuple[str, ...]) -> bool: - return not any(token in name for token in tokens) - - def _resolve_device(self): - if torch is not None and torch_directml is not None: - try: - return torch_directml.device(), "directml" - except Exception: - pass - if torch is not None: - return torch.device("cpu"), "cpu" - return None, "numpy" - - def _load_cache(self) -> Dict[str, Any]: - if not self.cache_path.exists(): - return {} - try: - return json.loads(self.cache_path.read_text(encoding="utf-8")) - except Exception: - return {} - - def _save_cache(self) -> None: - try: - self.cache_path.write_text(json.dumps(self._cache, indent=2), encoding="utf-8") - except Exception: - pass - - def _cache_key(self, path: Path) -> str: - return str(path.resolve()).lower() - - def _fingerprint(self, path: Path) -> str: - stat = path.stat() - return f"{stat.st_size}:{stat.st_mtime_ns}" - - def _analysis_cache_key(self, path: Path, duration_limit: Optional[float] = None) -> str: - suffix = "full" if duration_limit is None else f"{float(duration_limit):.3f}" - return f"{self._cache_key(path)}|{suffix}" - - def _segment_index_cache_prefix(self, path: Path, windows: set) -> str: - path_key = hashlib.sha1(self._cache_key(path).encode("utf-8")).hexdigest()[:16] - fingerprint = hashlib.sha1(self._fingerprint(path).encode("utf-8")).hexdigest()[:12] - windows_key = "-".join(f"{float(item):.2f}" for item in sorted(float(value) for value in windows)) or "full" - return f"{path_key}__{fingerprint}__{windows_key}" - - def _segment_index_cache_path(self, path: Path, windows: set, duration_limit: float) -> Path: - prefix = self._segment_index_cache_prefix(path, windows) - duration_key = f"{float(duration_limit):.2f}" - return self.segment_index_dir / f"{prefix}__{duration_key}.json.gz" - - def _get_segment_rag_state_path(self) -> Path: - """Get the path to the segment RAG indexing state file.""" - return self.segment_index_dir / "indexing_state.json" - - def _save_segment_rag_state(self, state: Dict[str, Any]) -> None: - """Save segment RAG indexing state to disk.""" - state_path = self._get_segment_rag_state_path() - state_path.parent.mkdir(parents=True, exist_ok=True) - with open(state_path, "w", encoding="utf-8") as f: - json.dump(state, f, indent=2) - - def _load_segment_rag_state(self) -> Dict[str, Any]: - """Load segment RAG indexing state from disk.""" - state_path = self._get_segment_rag_state_path() - if not state_path.exists(): - return {} - try: - with open(state_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - logger.warning("Failed to load segment RAG state, starting fresh", exc_info=True) - return {} - - def _load_segment_bank_from_disk(self, path: Path, windows: set, duration_limit: float) -> List[Dict[str, Any]]: - cache_path = self._segment_index_cache_path(path, windows, duration_limit) - candidate_paths = [cache_path] - if not cache_path.exists(): - prefix = self._segment_index_cache_prefix(path, windows) - candidate_paths = sorted(self.segment_index_dir.glob(f"{prefix}__*.json.gz"), reverse=True) - if not candidate_paths: - return [] - try: - for candidate_path in candidate_paths: - with gzip.open(candidate_path, "rt", encoding="utf-8") as handle: - payload = json.load(handle) - # Handle new format with metadata - if isinstance(payload, dict): - return payload.get("segments", []) or [] - # Handle old format (list of segments) - if isinstance(payload, list): - return payload - except Exception: - logger.debug("Failed to load segment cache for %s", path, exc_info=True) - return [] - - def _save_segment_bank_to_disk(self, path: Path, windows: set, duration_limit: float, bank: List[Dict[str, Any]], metadata: Optional[Dict[str, Any]] = None) -> None: - cache_path = self._segment_index_cache_path(path, windows, duration_limit) - try: - payload: Dict[str, Any] = {"segments": bank} - if metadata: - payload["metadata"] = { - "file_name": metadata.get("file_name") or path.name, - "path": metadata.get("path") or str(path), - "roles": metadata.get("roles") or [], - "windows": sorted(float(w) for w in windows) if windows else [], - "duration_limit": float(duration_limit), - "indexed_at": time.time(), - } - with gzip.open(cache_path, "wt", encoding="utf-8") as handle: - json.dump(payload, handle) - except Exception: - logger.debug("Failed to save segment cache for %s", path, exc_info=True) - - def _load_vector_store_metadata(self) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]: - by_path: Dict[str, Dict[str, Any]] = {} - by_name: Dict[str, Dict[str, Any]] = {} - metadata_path = self.vector_store_dir / "metadata.json" - if not metadata_path.exists(): - return by_path, by_name - - try: - payload = json.loads(metadata_path.read_text(encoding="utf-8")) - except Exception as exc: - logger.debug("No se pudo leer metadata del vector store: %s", exc) - return by_path, by_name - - for item in payload if isinstance(payload, list) else []: - if not isinstance(item, dict): - continue - file_name = str(item.get("filename", "") or "").strip().lower() - actual_path = self.library_dir / str(item.get("filename", "") or "") - if not actual_path.exists(): - actual_path = self.library_dir / Path(str(item.get("path", "") or "")).name - if not actual_path.exists(): - continue - normalized = str(actual_path.resolve()).lower() - normalized_item = dict(item) - normalized_item["resolved_path"] = str(actual_path) - by_path[normalized] = normalized_item - if file_name and file_name not in by_name: - by_name[file_name] = normalized_item - return by_path, by_name - - def _load_sample_index_metadata(self) -> Dict[str, Dict[str, Any]]: - if not self.sample_index_path.exists(): - return {} - - try: - payload = json.loads(self.sample_index_path.read_text(encoding="utf-8")) - except Exception as exc: - logger.debug("No se pudo leer sample index: %s", exc) - return {} - - entries = payload.get("samples", []) if isinstance(payload, dict) else [] - by_path: Dict[str, Dict[str, Any]] = {} - for item in entries if isinstance(entries, list) else []: - if not isinstance(item, dict): - continue - file_path = Path(str(item.get("path", "") or "")) - if not file_path.exists(): - continue - by_path[str(file_path.resolve()).lower()] = dict(item) - return by_path - - def _build_blocks(self, rms: np.ndarray, onset: np.ndarray, sr: int, - hop_length: int = 512, block_seconds: float = 8.0) -> List[Dict[str, float]]: - block_size = max(1, int(round(block_seconds * sr / hop_length))) - blocks: List[Dict[str, float]] = [] - for index in range(0, len(rms), block_size): - block_rms = rms[index:index + block_size] - block_onset = onset[index:index + block_size] - if len(block_rms) == 0: - continue - start = index * hop_length / sr - end = min(len(rms) * hop_length / sr, (index + block_size) * hop_length / sr) - blocks.append({ - "start": round(float(start), 3), - "end": round(float(end), 3), - "rms": round(float(np.mean(block_rms)), 6), - "onset": round(float(np.mean(block_onset)), 6), - "energy": round(float(np.mean(block_rms) * 0.65 + np.mean(block_onset) * 0.35), 6), - }) - return blocks - - def _vectorize_analysis(self, analysis: Dict[str, Any]) -> List[float]: - chroma = list(analysis.get("chroma", [0.0] * 12)) - return [ - float(analysis.get("tempo", 0.0)) / 180.0, - min(float(analysis.get("duration", 0.0)), 240.0) / 240.0, - float(analysis.get("rms_mean", 0.0)), - float(analysis.get("rms_std", 0.0)), - min(float(analysis.get("onset_mean", 0.0)), 8.0) / 8.0, - min(float(analysis.get("onset_std", 0.0)), 8.0) / 8.0, - min(float(analysis.get("spectral_centroid", 0.0)), 10000.0) / 10000.0, - min(float(analysis.get("spectral_rolloff", 0.0)), 14000.0) / 14000.0, - ] + chroma - - def _compute_audio_descriptor( - self, - y: np.ndarray, - sr: int, - tempo_hint: float = 0.0, - duration_hint: float = 0.0, - ) -> Dict[str, Any]: - y = np.asarray(y, dtype=np.float32) - if y.size == 0: - return { - "deep_vector": [0.0] * 53, - "harmonic_ratio": 0.5, - "percussive_ratio": 0.5, - "spectral_bandwidth": 0.0, - "spectral_bandwidth_std": 0.0, - "spectral_flatness": 0.0, - "spectral_flatness_std": 0.0, - "zero_crossing_rate": 0.0, - "zero_crossing_rate_std": 0.0, - "mfcc": [0.0] * 13, - "spectral_contrast": [0.0] * 7, - } - - if y.size < 512: - y = np.pad(y, (0, 512 - y.size)) - - hop_length = 256 if y.size < sr * 2 else 512 - n_fft = _adaptive_n_fft(len(y), default_n_fft=2048, min_n_fft=256) - - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length) - rms = librosa.feature.rms(y=y, hop_length=hop_length)[0] - centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0] - rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0] - bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length)[0] - flatness = librosa.feature.spectral_flatness(y=y, n_fft=n_fft, hop_length=hop_length)[0] - zcr = librosa.feature.zero_crossing_rate(y, hop_length=hop_length)[0] - try: - chroma = librosa.feature.chroma_cqt(y=y, sr=sr) - except Exception: - chroma = librosa.feature.chroma_stft(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length) - mfcc = librosa.feature.mfcc(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length, n_mfcc=13) - contrast = librosa.feature.spectral_contrast(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length) - - try: - harmonic, percussive = librosa.effects.hpss(y) - total_energy = float(np.sum(np.abs(y))) or 1.0 - harmonic_ratio = float(np.sum(np.abs(harmonic)) / total_energy) - percussive_ratio = float(np.sum(np.abs(percussive)) / total_energy) - except Exception: - harmonic_ratio = 0.5 - percussive_ratio = 0.5 - - chroma_avg = _normalize_chroma(np.mean(chroma, axis=1)) - mfcc_avg = np.mean(mfcc, axis=1) - contrast_avg = np.mean(contrast, axis=1) - duration = float(duration_hint or librosa.get_duration(y=y, sr=sr)) - - deep_vector = [ - min(float(tempo_hint or 0.0), 220.0) / 220.0, - min(duration, 240.0) / 240.0, - min(float(np.mean(rms)), 1.0), - min(float(np.std(rms)), 1.0), - min(float(np.mean(onset_env)), 8.0) / 8.0, - min(float(np.std(onset_env)), 8.0) / 8.0, - min(float(np.mean(centroid)), 12000.0) / 12000.0, - min(float(np.std(centroid)), 6000.0) / 6000.0, - min(float(np.mean(rolloff)), 16000.0) / 16000.0, - min(float(np.std(rolloff)), 8000.0) / 8000.0, - min(float(np.mean(bandwidth)), 8000.0) / 8000.0, - min(float(np.std(bandwidth)), 4000.0) / 4000.0, - min(float(np.mean(flatness)), 1.0), - min(float(np.std(flatness)), 1.0), - min(float(np.mean(zcr)), 1.0), - min(float(np.std(zcr)), 1.0), - min(max(harmonic_ratio, 0.0), 1.0), - min(max(percussive_ratio, 0.0), 1.0), - ] + [float(item) for item in chroma_avg.tolist()] \ - + [float(np.clip(item / 100.0, -1.0, 1.0)) for item in mfcc_avg.tolist()] \ - + [min(float(item), 80.0) / 80.0 for item in contrast_avg.tolist()] - - return { - "deep_vector": [round(float(item), 6) for item in deep_vector], - "harmonic_ratio": round(float(harmonic_ratio), 6), - "percussive_ratio": round(float(percussive_ratio), 6), - "spectral_bandwidth": round(float(np.mean(bandwidth)), 3), - "spectral_bandwidth_std": round(float(np.std(bandwidth)), 3), - "spectral_flatness": round(float(np.mean(flatness)), 6), - "spectral_flatness_std": round(float(np.std(flatness)), 6), - "zero_crossing_rate": round(float(np.mean(zcr)), 6), - "zero_crossing_rate_std": round(float(np.std(zcr)), 6), - "mfcc": [round(float(item), 6) for item in mfcc_avg.tolist()], - "spectral_contrast": [round(float(item), 6) for item in contrast_avg.tolist()], - } - - def _section_kind_at_time(self, sections: List[Dict[str, Any]], seconds: float) -> str: - for section in sections: - start = float(section.get("start", 0.0) or 0.0) - end = float(section.get("end", start) or start) - if start <= seconds < end: - return str(section.get("kind", "verse") or "verse").lower() - return str(sections[-1].get("kind", "verse") if sections else "verse").lower() - - def _build_reference_segment_bank( - self, - reference_path: str, - reference: Dict[str, Any], - sections: List[Dict[str, Any]], - ) -> List[Dict[str, Any]]: - path = Path(reference_path) - fingerprint = self._fingerprint(path) - cache_key = f"segments::{self._cache_key(path)}::{fingerprint}" - cached = self._cache.get(cache_key) - if isinstance(cached, list) and cached: - return cached - - y, sr = librosa.load(str(path), sr=22050, mono=True) - duration = float(librosa.get_duration(y=y, sr=sr)) - tempo = float(reference.get("tempo", 0.0) or 0.0) - bank: List[Dict[str, Any]] = [] - - for window_seconds in (1.0, 2.0, 4.0, 8.0): - hop_seconds = max(0.25, window_seconds / 2.0) - cursor = 0.0 - while cursor + 0.25 <= duration: - end = min(duration, cursor + window_seconds) - start_sample = int(cursor * sr) - end_sample = max(start_sample + 256, int(end * sr)) - segment_audio = y[start_sample:end_sample] - if segment_audio.size < 256: - cursor += hop_seconds - continue - descriptor = self._compute_audio_descriptor( - segment_audio, - sr, - tempo_hint=tempo, - duration_hint=end - cursor, - ) - midpoint = cursor + ((end - cursor) / 2.0) - bank.append({ - "start": round(float(cursor), 3), - "end": round(float(end), 3), - "window_seconds": round(float(end - cursor), 3), - "kind": self._section_kind_at_time(sections, midpoint), - "vector": descriptor.get("deep_vector", []), - "rms_mean": descriptor.get("deep_vector", [0.0, 0.0, 0.0])[2] if descriptor.get("deep_vector") else 0.0, - "onset_mean": descriptor.get("deep_vector", [0.0] * 5)[4] if descriptor.get("deep_vector") else 0.0, - "spectral_centroid": round(float(descriptor.get("deep_vector", [0.0] * 7)[6] * 12000.0), 3) if descriptor.get("deep_vector") else 0.0, - "spectral_rolloff": round(float(descriptor.get("deep_vector", [0.0] * 9)[8] * 16000.0), 3) if descriptor.get("deep_vector") else 0.0, - "harmonic_ratio": descriptor.get("harmonic_ratio", 0.5), - "percussive_ratio": descriptor.get("percussive_ratio", 0.5), - "spectral_flatness": descriptor.get("spectral_flatness", 0.0), - "zero_crossing_rate": descriptor.get("zero_crossing_rate", 0.0), - }) - cursor += hop_seconds - - self._cache[cache_key] = bank - self._save_cache() - return bank - - def _build_candidate_segment_bank( - self, - candidate_path: str, - windows: set, - duration_limit: float = 32.0, - metadata: Optional[Dict[str, Any]] = None, - ) -> List[Dict[str, Any]]: - path = Path(candidate_path) - if not path.exists(): - return [] - - fingerprint = self._fingerprint(path) - windows_key = ",".join(str(item) for item in sorted(float(value) for value in windows)) or "full" - cache_key = f"candidate_segments::{self._cache_key(path)}::{fingerprint}::{windows_key}::{float(duration_limit):.3f}" - cached = self._cache.get(cache_key) - if isinstance(cached, list) and cached: - return cached - disk_cached = self._load_segment_bank_from_disk(path, windows, duration_limit) - if disk_cached: - self._cache[cache_key] = disk_cached - return disk_cached - - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - y, sr = librosa.load(str(path), sr=22050, mono=True, duration=duration_limit) - - file_duration = float(librosa.get_duration(y=y, sr=sr)) - bank: List[Dict[str, Any]] = [] - - for window_seconds in sorted(float(value) for value in windows if float(value) > 0.0): - if file_duration <= 0.0: - continue - hop_seconds = max(0.25, window_seconds / 2.0) - cursor = 0.0 - while cursor + 0.25 <= file_duration: - end = min(file_duration, cursor + window_seconds) - start_sample = int(cursor * sr) - end_sample = max(start_sample + 256, int(end * sr)) - segment_audio = y[start_sample:end_sample] - if segment_audio.size < 256: - cursor += hop_seconds - continue - descriptor = self._compute_audio_descriptor( - segment_audio, - sr, - duration_hint=end - cursor, - ) - bank.append({ - "start": round(float(cursor), 3), - "end": round(float(end), 3), - "window_seconds": round(float(end - cursor), 3), - "vector": descriptor.get("deep_vector", []), - }) - cursor += hop_seconds - - self._cache[cache_key] = bank - self._save_segment_bank_to_disk(path, windows, duration_limit, bank, metadata=metadata) - self._save_cache() - return bank - - def analyze_file(self, file_path: str, duration_limit: Optional[float] = None) -> Dict[str, Any]: - if librosa is None: - raise RuntimeError("librosa no está disponible") - - path = Path(file_path) - cache_key = self._analysis_cache_key(path, duration_limit) - legacy_key = self._cache_key(path) - fingerprint = self._fingerprint(path) - cached = self._cache.get(cache_key) - if not isinstance(cached, dict) and duration_limit is None: - cached = self._cache.get(legacy_key) - if isinstance(cached, dict) and cached.get("fingerprint") == fingerprint: - return dict(cached["analysis"]) - - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - y, sr = librosa.load(str(path), sr=22050, mono=True, duration=duration_limit) - hop_length = 512 - n_fft = _adaptive_n_fft(len(y)) - onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length) - rms = librosa.feature.rms(y=y, hop_length=hop_length)[0] - centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft)[0] - rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, n_fft=n_fft)[0] - try: - chroma = librosa.feature.chroma_cqt(y=y, sr=sr) - except Exception: - chroma = librosa.feature.chroma_stft(y=y, sr=sr, n_fft=n_fft, hop_length=hop_length) - chroma_avg = _normalize_chroma(np.mean(chroma, axis=1)) - key, key_score = _detect_key(chroma_avg) - tempo = _safe_float(librosa.feature.tempo(onset_envelope=onset_env, sr=sr, aggregate=np.median)) - - analysis = { - "path": str(path), - "file_name": path.name, - "duration": round(float(librosa.get_duration(y=y, sr=sr)), 3), - "tempo": round(float(tempo), 3), - "key": key, - "key_confidence": round(float(key_score), 6), - "rms_mean": round(float(np.mean(rms)), 6), - "rms_std": round(float(np.std(rms)), 6), - "onset_mean": round(float(np.mean(onset_env)), 6), - "onset_std": round(float(np.std(onset_env)), 6), - "spectral_centroid": round(float(np.mean(centroid)), 3), - "spectral_rolloff": round(float(np.mean(rolloff)), 3), - "chroma": [round(float(item), 6) for item in chroma_avg.tolist()], - "blocks": self._build_blocks(rms, onset_env, sr, hop_length=hop_length), - } - analysis["vector"] = self._vectorize_analysis(analysis) - analysis.update(self._compute_audio_descriptor( - y, - sr, - tempo_hint=float(analysis.get("tempo", 0.0) or 0.0), - duration_hint=float(analysis.get("duration", 0.0) or 0.0), - )) - - self._cache[cache_key] = { - "fingerprint": fingerprint, - "analysis": analysis, - } - if duration_limit is None: - self._cache[legacy_key] = self._cache[cache_key] - self._save_cache() - return dict(analysis) - - def analyze_reference(self, reference_path: str) -> Dict[str, Any]: - analysis = self.analyze_file(reference_path) - energies = [float(block.get("energy", 0.0)) for block in analysis.get("blocks", [])] - if energies: - max_energy = max(energies) or 1.0 - for block in analysis["blocks"]: - block["energy_norm"] = round(float(block["energy"]) / max_energy, 6) - analysis["device"] = self.device_name - return analysis - - def _is_excluded_full_track(self, path: Path, sample_meta: Optional[Dict[str, Any]], vector_meta: Optional[Dict[str, Any]]) -> bool: - vector_type = str((vector_meta or {}).get("type", "") or "").lower() - if vector_type == "full_track": - return True - - duration_estimate = float((vector_meta or {}).get("duration_estimate", 0.0) or 0.0) - category = str((sample_meta or {}).get("category", "") or "").lower() - if path.suffix.lower() == ".mp3" and duration_estimate >= 45.0: - return True - - if path.suffix.lower() == ".mp3" and path.exists(): - size_bytes = int((vector_meta or {}).get("size_bytes", (sample_meta or {}).get("size", 0)) or 0) - if size_bytes >= 5_000_000 and category in {"unknown", "loop"}: - return True - return False - - def _duration_estimate( - self, - path: Path, - sample_meta: Optional[Dict[str, Any]], - vector_meta: Optional[Dict[str, Any]], - ) -> float: - duration_estimate = float((vector_meta or {}).get("duration_estimate", 0.0) or 0.0) - if duration_estimate > 0.0: - return duration_estimate - sample_duration = float((sample_meta or {}).get("duration", 0.0) or 0.0) - if sample_duration > 0.0: - return sample_duration - sample_size = int((sample_meta or {}).get("size", 0) or 0) - if sample_size > 0: - return min(32.0, max(0.1, sample_size / 176400.0)) - try: - return min(32.0, max(0.1, path.stat().st_size / 176400.0)) - except Exception: - return 0.0 - - def _catalog_role_match( - self, - role: str, - path: Path, - sample_meta: Optional[Dict[str, Any]], - vector_meta: Optional[Dict[str, Any]], - ) -> bool: - if self._is_excluded_full_track(path, sample_meta, vector_meta): - return False - - name = path.name - stem = path.stem.lower() - name_match = self._matches_role_name(role, name) - - category = str((sample_meta or {}).get("category", "") or "").lower() - vector_type = str((vector_meta or {}).get("type", "") or "").lower() - duration_estimate = self._duration_estimate(path, sample_meta, vector_meta) - - role_categories = { - "kick": {"kick"}, - "snare": {"snare", "clap"}, - "hat": {"hat"}, - "bass_loop": {"bass"}, - "perc_loop": {"perc"}, - "top_loop": {"loop"}, - "synth_loop": {"synth"}, - "vocal_loop": {"vocal"}, - "crash_fx": {"fx"}, - "fill_fx": {"fx"}, - "snare_roll": {"fx"}, - "atmos_fx": {"fx", "synth"}, - "vocal_shot": {"vocal"}, - } - role_types = ROLE_VECTOR_TYPES.get(role, set()) - min_dur, max_dur = ROLE_DURATION_WINDOWS.get(role, (0.0, 999.0)) - duration_ok = duration_estimate <= 0.0 or (min_dur <= duration_estimate <= max_dur) - loopish_name = self._name_contains_any(stem, ("loop", "groove", "full drum", "full mix", "drum loop", "top loop")) - - if name_match: - return duration_ok - if role in {"kick", "snare", "hat"}: - return bool(category and category in role_categories.get(role, set()) and duration_ok) - if role == "bass_loop": - if category == "bass" and duration_ok: - return True - if vector_type and vector_type in role_types and duration_ok and self._name_contains_none(stem, ("drum loop", "full mix", "top loop", "vocal")): - return True - return False - if role == "perc_loop": - if category == "perc" and duration_ok and loopish_name: - return True - if vector_type and vector_type in role_types and duration_ok and loopish_name: - return True - return False - if role == "top_loop": - if category == "loop" and duration_ok and loopish_name and self._name_contains_none(stem, ("bass loop", "vocal", "synth loop")): - return True - if vector_type and vector_type in role_types and duration_ok and loopish_name: - return True - return False - if role == "synth_loop": - synthish_name = self._name_contains_any(stem, ("synth", "lead", "hook", "pluck", "pad", "chord", "arp", "melod")) - if category == "synth" and duration_ok and synthish_name: - return True - if vector_type and vector_type in role_types and duration_ok and synthish_name: - return True - return False - if role == "vocal_loop": - vocalish_loop = self._name_contains_any(stem, ("vocal loop", "vox", "acapella", "chant", "phrase", "vocal")) - if category == "vocal" and duration_ok and vocalish_loop and self._name_contains_none(stem, ("one shot", "shot", "importante", "stab", "hit")): - return True - if vector_type and vector_type in role_types and duration_ok and vocalish_loop and self._name_contains_none(stem, ("one shot", "shot", "importante", "stab", "hit")): - return True - return False - if role == "crash_fx": - return False - if role == "fill_fx": - if category == "fx" and duration_ok and self._name_contains_any(stem, ("fill", "transition", "tom loop", "drum fill", "break fill")): - return True - return False - if role == "snare_roll": - if category == "fx" and duration_ok and self._name_contains_any(stem, ("snareroll", "snare roll", "roll", "buildup")): - return True - return False - if role == "atmos_fx": - atmosish_name = self._name_contains_any(stem, ("atmos", "drone", "ambient", "noise", "texture", "downfilter", "sweep", "wash")) - if category in role_categories.get(role, set()) and duration_ok and atmosish_name: - return True - if vector_type and vector_type in role_types and duration_ok and atmosish_name: - return True - return False - if role == "vocal_shot": - if category == "vocal" and duration_ok and duration_estimate <= 3.0: - return True - if vector_type in role_types and duration_ok and duration_estimate <= 3.0: - return True - return False - return False - - def prewarm_library_matching_cache( - self, - roles: Optional[List[str]] = None, - max_files: Optional[int] = None, - duration_limit: float = 32.0, - ) -> Dict[str, Any]: - target_roles = [role for role in (roles or list(self.ROLE_PATTERNS.keys())) if role in self.ROLE_PATTERNS] - assets = self._list_assets() - windows_by_role = { - role: set(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set()) - for role in target_roles - } - - files: Dict[str, Tuple[Path, set]] = {} - for role in target_roles: - for file_path in assets.get(role, []): - normalized = str(file_path.resolve()).lower() - if normalized not in files: - files[normalized] = (file_path, set()) - files[normalized][1].update(windows_by_role.get(role, set())) - - ordered_files = list(files.values()) - if max_files is not None: - ordered_files = ordered_files[: max(0, int(max_files))] - - analyzed = 0 - segmented = 0 - errors = 0 - for file_path, windows in ordered_files: - try: - analysis = self.analyze_file(str(file_path), duration_limit=duration_limit) - analyzed += 1 - if float(analysis.get("duration", 0.0) or 0.0) > max(windows or {4.0}) * 1.5: - self._build_candidate_segment_bank(str(file_path), windows or {4.0}, duration_limit=duration_limit) - segmented += 1 - except Exception: - errors += 1 - - return { - "roles": target_roles, - "files_considered": len(ordered_files), - "analyzed": analyzed, - "segmented": segmented, - "errors": errors, - "cache_path": str(self.cache_path), - "device": self.device_name, - } - - def build_segment_rag_index( - self, - roles: Optional[List[str]] = None, - max_files: Optional[int] = None, - duration_limit: float = 32.0, - force: bool = False, - offset: int = 0, - batch_size: Optional[int] = None, - resume: bool = False, - ) -> Dict[str, Any]: - target_roles = [role for role in (roles or list(self.ROLE_PATTERNS.keys())) if role in self.ROLE_PATTERNS] - assets = self._list_assets() - files: Dict[str, Dict[str, Any]] = {} - - for role in target_roles: - for file_path in assets.get(role, []): - normalized = str(file_path.resolve()).lower() - if normalized not in files: - files[normalized] = { - "path": file_path, - "roles": set(), - "windows": set(), - } - files[normalized]["roles"].add(role) - files[normalized]["windows"].update(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set()) - - ordered_files = sorted( - files.values(), - key=lambda item: ( - -len(item["roles"]), - -sum(float(value) for value in item["windows"]), - item["path"].name.lower(), - ), - ) - - state = self._load_segment_rag_state() - indexed_entries = dict(state.get("indexed_entries", {}) or {}) - if resume: - indexed_paths = set(state.get("indexed_paths", []) or []) - if indexed_paths: - before_resume = len(ordered_files) - ordered_files = [ - entry for entry in ordered_files - if str(entry["path"].resolve()).lower() not in indexed_paths - ] - logger.info( - "Resume mode: skipped %d already indexed files, %d remaining", - before_resume - len(ordered_files), - len(ordered_files), - ) - - total_available = len(ordered_files) - if offset > 0: - ordered_files = ordered_files[offset:] - - limit = batch_size if batch_size is not None else max_files - if limit is not None: - ordered_files = ordered_files[: max(0, int(limit))] - - files_remaining = max(0, total_available - offset - len(ordered_files)) - - built = 0 - reused = 0 - skipped = 0 - errors = 0 - total_segments = 0 - manifest: List[Dict[str, Any]] = [] - - for entry in ordered_files: - path = entry["path"] - windows = entry["windows"] or {4.0} - normalized = str(path.resolve()).lower() - sample_meta = self._sample_index_by_path.get(normalized) - vector_meta = self._vector_store_meta_by_path.get(normalized) - estimated_duration = self._duration_estimate(path, sample_meta, vector_meta) - effective_duration_limit = min(max(estimated_duration, 0.5), duration_limit) if estimated_duration > 0.0 else duration_limit - disk_cached = self._load_segment_bank_from_disk(path, windows, effective_duration_limit) - cache_prefix = self._segment_index_cache_prefix(path, windows) - if disk_cached and not force: - reused += 1 - total_segments += len(disk_cached) - entry_report = { - "file_name": path.name, - "path": str(path), - "roles": sorted(entry["roles"]), - "segments": len(disk_cached), - "cached": True, - "cache_prefix": cache_prefix, - } - manifest.append(entry_report) - indexed_entries[normalized] = entry_report - continue - try: - analysis = self.analyze_file(str(path), duration_limit=duration_limit) - duration = float(analysis.get("duration", 0.0) or 0.0) - if duration < 0.5: - skipped += 1 - continue - segment_metadata = { - "file_name": path.name, - "path": str(path), - "roles": sorted(entry["roles"]), - } - bank = self._build_candidate_segment_bank(str(path), windows, duration_limit=min(max(duration, 0.5), duration_limit), metadata=segment_metadata) - built += 1 - total_segments += len(bank) - entry_report = { - "file_name": path.name, - "path": str(path), - "roles": sorted(entry["roles"]), - "segments": len(bank), - "cached": False, - "cache_prefix": cache_prefix, - } - manifest.append(entry_report) - indexed_entries[normalized] = entry_report - - if (built + reused) % 10 == 0: - periodic_state = { - "indexed_files": [item["file_name"] for item in indexed_entries.values()], - "indexed_paths": list(indexed_entries.keys()), - "indexed_entries": indexed_entries, - "last_offset": offset + (built + reused), - "total_processed": len(indexed_entries), - "timestamp": time.time(), - } - self._save_segment_rag_state(periodic_state) - logger.debug("Saved segment RAG state after %d processed files", built + reused) - except Exception: - errors += 1 - logger.debug("Failed to build segment index for %s", path, exc_info=True) - - final_state = { - "indexed_files": [item["file_name"] for item in indexed_entries.values()], - "indexed_paths": list(indexed_entries.keys()), - "indexed_entries": indexed_entries, - "last_offset": offset + (built + reused), - "total_processed": len(indexed_entries), - "timestamp": time.time(), - "complete": files_remaining == 0, - } - self._save_segment_rag_state(final_state) - - return { - "roles": target_roles, - "files_targeted": len(ordered_files), - "total_available": total_available, - "built": built, - "reused": reused, - "skipped": skipped, - "errors": errors, - "total_segments": total_segments, - "segment_index_dir": str(self.segment_index_dir), - "device": self.device_name, - "manifest": manifest, - "offset": offset, - "batch_size": batch_size, - "files_remaining": files_remaining, - "resumed": resume, - } - - def _list_assets(self) -> Dict[str, List[Path]]: - assets: Dict[str, List[Path]] = {role: [] for role in self.ROLE_PATTERNS} - if not self.library_dir.exists(): - return assets - - for role, patterns in self.ROLE_PATTERNS.items(): - seen = set() - indexed_paths = set(self._sample_index_by_path.keys()) | set(self._vector_store_meta_by_path.keys()) - - for normalized_path in sorted(indexed_paths): - path = Path(normalized_path) - if not path.exists() or not path.is_file(): - continue - if path.suffix.lower() not in {'.wav', '.aif', '.aiff', '.mp3'}: - continue - sample_meta = self._sample_index_by_path.get(normalized_path) - vector_meta = self._vector_store_meta_by_path.get(normalized_path) - if not self._catalog_role_match(role, path, sample_meta, vector_meta): - continue - if normalized_path in seen: - continue - seen.add(normalized_path) - assets[role].append(path) - - for pattern in patterns: - for match in sorted(self.library_dir.glob(pattern)): - if match.is_file() and match.suffix.lower() in {'.wav', '.aif', '.aiff', '.mp3'}: - normalized_match = str(match.resolve()).lower() - sample_meta = self._sample_index_by_path.get(normalized_match) - vector_meta = self._vector_store_meta_by_path.get(normalized_match) - if not self._catalog_role_match(role, match, sample_meta, vector_meta): - continue - if normalized_match in seen: - continue - seen.add(normalized_match) - assets[role].append(match) - return assets - - def _apply_role_exclusions(self, role: str, file_name: str) -> Tuple[bool, str]: - """ - Aplica exclusiones fuertes por rol. - - Retorna: - (should_reject, reason) - True si debe rechazar, False si pasa - """ - role_lower = role.lower() - if role_lower not in self.ROLE_EXCLUSION_PATTERNS: - return False, "" - - name_lower = file_name.lower() - exclusions = self.ROLE_EXCLUSION_PATTERNS[role_lower] - - for excl in exclusions: - if excl in name_lower: - return True, f"excluded pattern '{excl}' for role '{role}'" - - return False, "" - - def _validate_role_requirement(self, role: str, item: Dict[str, Any]) -> Tuple[bool, float, str]: - """ - Validates that a candidate sample meets role requirements. - - Returns: - (passes, score_modifier, reason) - True if passes, score modifier (0-1), reason string - """ - role_lower = role.lower() - file_name = str(item.get("file_name", "") or "").lower() - duration = float(item.get("duration", 0.0) or 0.0) - - min_dur, max_dur = ROLE_DURATION_WINDOWS.get(role_lower, (0.0, 999.0)) - - if duration > 0.0 and not (min_dur <= duration <= max_dur): - return False, 0.0, f"duration {duration:.1f}s outside range [{min_dur}, {max_dur}] for role {role}" - - if role_lower in {'kick', 'snare', 'hat', 'clap', 'hat_closed', 'hat_open'}: - if 'loop' in file_name and 'full' not in file_name: - if duration > 4.0: - return False, 0.3, f"one-shot role {role} has loop-like file (duration={duration:.1f}s)" - - if role_lower in {'bass_loop', 'vocal_loop', 'top_loop', 'synth_loop'}: - if duration < 1.0: - return False, 0.2, f"loop role {role} has very short duration ({duration:.1f}s)" - - must_contain = { - 'kick': ['kick', 'bd', 'bass_drum', '808'], - 'snare': ['snare', 'snr', 'sd', 'rim'], - 'clap': ['clap', 'clp', 'hand'], - 'hat': ['hat', 'hh', 'hihat', 'cymbal'], - 'bass_loop': ['bass', 'sub', 'reese', '808', 'bassline'], - 'vocal_loop': ['vocal', 'vox', 'voice', 'chant', 'acapella'], - 'top_loop': ['top', 'perc', 'drum', 'full'], - 'synth_loop': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck'], - 'crash_fx': ['crash', 'cymbal', 'impact', 'ride'], - 'fill_fx': ['fill', 'transition', 'tom'], - 'snare_roll': ['roll', 'snare', 'build'], - 'atmos_fx': ['atmos', 'drone', 'ambient', 'texture', 'noise'], - 'vocal_shot': ['vocal', 'vox', 'shot', 'chop', 'stab'], - } - - if role_lower in must_contain: - found = any(kw in file_name for kw in must_contain[role_lower]) - if not found: - return True, 0.65, f"no role keyword for {role}" - - return True, 1.0, "passes role validation" - - def _matches_role_name(self, role: str, file_name: str) -> bool: - name = Path(file_name).stem.lower() - # Check exclusions first - should_reject, reason = self._apply_role_exclusions(role, name) - if should_reject: - logger.debug("ROLE_EXCLUSION: %s", reason) - return False - if role == 'kick': - return 'kick' in name and 'loop' not in name - if role == 'snare': - return ('snare' in name or 'clap' in name) and 'roll' not in name and 'loop' not in name - if role == 'hat': - return 'hat' in name and 'loop' not in name and 'full mix' not in name - if role == 'bass_loop': - return self._name_contains_any(name, ('bass loop', 'bass_loop', 'bassline', 'sub bass', 'sub_bass', 'reese', '808')) \ - and self._name_contains_none(name, ('drum loop', 'full mix', 'top loop', 'vocal')) - if role == 'perc_loop': - return ( - self._name_contains_any(name, ('perc loop', 'perc_loop', 'percussion loop', 'drum loop', 'drum_loop', 'groove')) - or ('perc' in name and 'loop' in name) - or (self._name_contains_any(name, ('shaker', 'bongo', 'conga', 'timbale')) and 'loop' in name) - ) and self._name_contains_none(name, ('full mix', 'one shot', 'shot', 'vocal')) - if role == 'top_loop': - return self._name_contains_any(name, ('top loop', 'top_loop', 'top loops', 'full drum', 'full mix', 'drum loop', 'drum_loop')) \ - and self._name_contains_none(name, ('bass loop', 'vocal', 'synth loop')) - if role == 'synth_loop': - return ( - self._name_contains_any(name, ('synth loop', 'synth_loop', 'lead loop', 'lead_loop', 'hook', 'melody loop', 'melodic loop')) - or ('synth' in name and 'loop' in name) - or (self._name_contains_any(name, ('chord', 'pad', 'pluck', 'arp')) and 'loop' in name) - ) and self._name_contains_none(name, ('drum loop', 'full mix', 'vocal')) - if role == 'vocal_loop': - return ( - self._name_contains_any(name, ('vocal loop', 'vox loop', 'vox_', 'acapella', 'chant loop')) - or ('vocal' in name and 'loop' in name) - ) and self._name_contains_none(name, ('one shot', 'shot', 'importante', 'stab', 'hit')) - if role == 'crash_fx': - return self._name_contains_any(name, ('crash', 'cymbal', 'riser', 'downlifter', 'sweep', 'uplifter')) or ' impact ' in f" {name} " - if role == 'fill_fx': - return self._name_contains_any(name, ('fill', 'transition', 'tom loop', 'drum fill', 'break fill')) - if role == 'snare_roll': - return self._name_contains_any(name, ('snareroll', 'snare roll', 'roll', 'buildup')) and 'one shot' not in name - if role == 'atmos_fx': - return self._name_contains_any(name, ('atmos', 'drone', 'ambient', 'noise', 'texture', 'downfilter', 'wash', 'sweep')) - if role == 'vocal_shot': - return self._name_contains_any(name, ('vocal one shot', 'one shot', 'shot', 'importante', 'vocal chop', 'vocal stab')) - return True - - def _cosine_scores(self, reference_vector: List[float], candidate_vectors: List[List[float]]) -> List[float]: - if not candidate_vectors: - return [] - - ref = np.asarray(reference_vector, dtype=np.float32) - candidates = np.asarray(candidate_vectors, dtype=np.float32) - if torch is None or self.device is None or F is None: - ref_norm = np.linalg.norm(ref) or 1.0 - cand_norm = np.linalg.norm(candidates, axis=1) - cand_norm[cand_norm == 0] = 1.0 - return (candidates @ ref / (cand_norm * ref_norm)).astype(float).tolist() - - ref_tensor = torch.tensor(ref, dtype=torch.float32, device=self.device) - candidate_tensor = torch.tensor(candidates, dtype=torch.float32, device=self.device) - scores = F.cosine_similarity(candidate_tensor, ref_tensor.unsqueeze(0), dim=1) - return scores.detach().cpu().numpy().astype(float).tolist() - - def _cosine_matrix(self, left_vectors: List[List[float]], right_vectors: List[List[float]]) -> np.ndarray: - if not left_vectors or not right_vectors: - return np.zeros((0, 0), dtype=np.float32) - - left = np.asarray(left_vectors, dtype=np.float32) - right = np.asarray(right_vectors, dtype=np.float32) - - if torch is None or self.device is None or F is None: - left_norm = np.linalg.norm(left, axis=1, keepdims=True) - right_norm = np.linalg.norm(right, axis=1, keepdims=True) - left_norm[left_norm == 0] = 1.0 - right_norm[right_norm == 0] = 1.0 - return (left / left_norm) @ (right / right_norm).T - - left_tensor = torch.tensor(left, dtype=torch.float32, device=self.device) - right_tensor = torch.tensor(right, dtype=torch.float32, device=self.device) - left_tensor = F.normalize(left_tensor, p=2, dim=1) - right_tensor = F.normalize(right_tensor, p=2, dim=1) - return (left_tensor @ right_tensor.T).detach().cpu().numpy().astype(np.float32) - - def _tempo_score(self, candidate_tempo: float, reference_tempo: float) -> float: - if candidate_tempo <= 0 or reference_tempo <= 0: - return 0.5 - variants = [ - candidate_tempo, - candidate_tempo * 2.0, - candidate_tempo / 2.0, - candidate_tempo * 4.0, - candidate_tempo / 4.0, - ] - diff = min(abs(item - reference_tempo) for item in variants) - return math.exp(-diff / 10.0) - - def _vector_store_entry(self, candidate: Dict[str, Any]) -> Optional[Dict[str, Any]]: - path_key = str(candidate.get("path", "") or "").strip().lower() - if path_key and path_key in self._vector_store_meta_by_path: - return self._vector_store_meta_by_path[path_key] - file_name = str(candidate.get("file_name", "") or Path(path_key).name).strip().lower() - if file_name and file_name in self._vector_store_meta_by_name: - return self._vector_store_meta_by_name[file_name] - return None - - def _role_segment_relevance(self, role: str, segment: Dict[str, Any], reference: Dict[str, Any]) -> float: - kind = str(segment.get("kind", "verse") or "verse").lower() - centroid = float(segment.get("spectral_centroid", 0.0) or 0.0) - onset = float(segment.get("onset_mean", 0.0) or 0.0) - harmonic = float(segment.get("harmonic_ratio", 0.5) or 0.5) - percussive = float(segment.get("percussive_ratio", 0.5) or 0.5) - flatness = float(segment.get("spectral_flatness", 0.0) or 0.0) - zcr = float(segment.get("zero_crossing_rate", 0.0) or 0.0) - rms = float(segment.get("rms_mean", 0.5) or 0.5) - score = 0.0 - - if role == 'kick': - transient = min(1.0, onset / 3.0) - low_centroid = max(0.0, 1.0 - (centroid / 3000.0)) - score = transient * 0.35 + percussive * 0.30 + low_centroid * 0.20 + rms * 0.15 - elif role == 'snare': - transient = min(1.0, onset / 4.5) - mid_centroid = min(1.0, max(0.0, (centroid - 800) / 4000.0)) - score = transient * 0.32 + percussive * 0.28 + mid_centroid * 0.25 - elif role == 'hat': - high_centroid = min(1.0, centroid / 10000.0) - transient = min(1.0, onset / 4.0) - score = high_centroid * 0.38 + transient * 0.32 + zcr * 0.15 + percussive * 0.15 - elif role == 'bass_loop': - low_centroid = max(0.0, 1.0 - (centroid / 2200.0)) - harmonic_content = harmonic * 0.35 - low_flat = max(0.0, 1.0 - flatness * 1.5) - score = harmonic_content + low_centroid * 0.30 + low_flat * 0.20 + rms * 0.15 - elif role in {'perc_loop', 'top_loop'}: - transient = min(1.0, onset / 4.0) - mid_high_centroid = min(1.0, max(0.0, centroid / 8500.0)) - score = transient * 0.35 + percussive * 0.30 + mid_high_centroid * 0.20 + rms * 0.15 - elif role == 'synth_loop': - harmonic_content = harmonic * 0.38 - mid_centroid = min(1.0, max(0.0, (centroid - 500) / 7000.0)) - low_flat = max(0.0, 1.0 - flatness * 1.2) - score = harmonic_content + mid_centroid * 0.22 + low_flat * 0.25 - elif role == 'vocal_loop': - harmonic_content = harmonic * 0.32 - mid_centroid = min(1.0, max(0.0, (centroid - 200) / 4000.0)) - low_flat = max(0.0, 1.0 - flatness * 1.5) - score = harmonic_content + mid_centroid * 0.18 + low_flat * 0.25 + rms * 0.25 - elif role == 'crash_fx': - high_centroid = min(1.0, centroid / 12000.0) - transient = min(1.0, onset / 3.5) - high_flat = min(1.0, flatness * 2.5) - score = high_centroid * 0.30 + transient * 0.25 + high_flat * 0.25 - elif role == 'fill_fx': - transient = min(1.0, onset / 4.0) - percussive_content = percussive * 0.35 - mid_centroid = min(1.0, max(0.0, centroid / 7000.0)) - score = transient * 0.30 + percussive_content + mid_centroid * 0.20 - elif role == 'snare_roll': - transient = min(1.0, onset / 4.5) - percussive_content = percussive * 0.38 - mid_centroid = min(1.0, max(0.0, (centroid - 1000) / 5000.0)) - score = transient * 0.35 + percussive_content + mid_centroid * 0.15 - elif role == 'atmos_fx': - harmonic_content = harmonic * 0.28 - low_onset = max(0.0, 1.0 - onset * 2.0) - high_flat = min(1.0, flatness * 2.0) - score = harmonic_content + low_onset * 0.22 + high_flat * 0.25 + rms * 0.25 - elif role == 'vocal_shot': - harmonic_content = harmonic * 0.30 - transient = min(1.0, onset / 4.0) - mid_centroid = min(1.0, max(0.0, (centroid - 300) / 4500.0)) - score = harmonic_content + transient * 0.22 + mid_centroid * 0.28 - - section_bonus_map = { - 'kick': {'intro': 0.04, 'verse': 0.08, 'build': 0.12, 'drop': 0.18, 'break': -0.08, 'outro': 0.02}, - 'snare': {'intro': -0.06, 'verse': 0.06, 'build': 0.10, 'drop': 0.14, 'break': 0.03, 'outro': -0.04}, - 'hat': {'intro': 0.06, 'verse': 0.08, 'build': 0.14, 'drop': 0.12, 'break': -0.04, 'outro': 0.02}, - 'bass_loop': {'intro': -0.12, 'verse': 0.06, 'build': 0.12, 'drop': 0.20, 'break': -0.10, 'outro': -0.06}, - 'perc_loop': {'intro': 0.02, 'verse': 0.08, 'build': 0.14, 'drop': 0.18, 'break': 0.06, 'outro': 0.00}, - 'top_loop': {'intro': 0.04, 'verse': 0.08, 'build': 0.16, 'drop': 0.18, 'break': 0.02, 'outro': 0.00}, - 'synth_loop': {'intro': 0.06, 'verse': 0.04, 'build': 0.14, 'drop': 0.20, 'break': 0.12, 'outro': 0.02}, - 'vocal_loop': {'intro': -0.06, 'verse': 0.14, 'build': 0.08, 'drop': 0.16, 'break': 0.10, 'outro': -0.02}, - 'crash_fx': {'intro': 0.10, 'verse': 0.02, 'build': 0.16, 'drop': 0.10, 'break': -0.06, 'outro': 0.10}, - 'fill_fx': {'intro': 0.02, 'verse': 0.04, 'build': 0.20, 'drop': 0.12, 'break': 0.10, 'outro': 0.02}, - 'snare_roll': {'intro': -0.08, 'verse': 0.02, 'build': 0.26, 'drop': 0.14, 'break': 0.06, 'outro': -0.10}, - 'atmos_fx': {'intro': 0.22, 'verse': 0.04, 'build': 0.02, 'drop': -0.06, 'break': 0.24, 'outro': 0.18}, - 'vocal_shot': {'intro': -0.06, 'verse': 0.10, 'build': 0.12, 'drop': 0.16, 'break': 0.08, 'outro': -0.04}, - } - score += section_bonus_map.get(role, {}).get(kind, 0.0) - return max(0.0, min(1.0, score)) - - def _select_role_reference_segments( - self, - role: str, - reference: Dict[str, Any], - segment_bank: List[Dict[str, Any]], - ) -> List[Dict[str, Any]]: - if not segment_bank: - return [] - settings = ROLE_SEGMENT_SETTINGS.get(role, {}) - allowed_windows = settings.get("windows", set()) - allowed_kinds = settings.get("section_kinds", set()) - filtered = [ - segment for segment in segment_bank - if (not allowed_windows or round(float(segment.get("window_seconds", 0.0)), 1) in allowed_windows) - and (not allowed_kinds or str(segment.get("kind", "")).lower() in allowed_kinds) - ] - if not filtered: - filtered = segment_bank - ranked = sorted( - filtered, - key=lambda item: self._role_segment_relevance(role, item, reference), - reverse=True, - ) - return ranked[:int(settings.get("top_k", 6) or 6)] - - def _role_segment_similarity( - self, - role: str, - candidate: Dict[str, Any], - role_segments: List[Dict[str, Any]], - ) -> float: - role_vectors = [list(segment.get("vector", []) or []) for segment in role_segments if segment.get("vector")] - if not role_vectors: - return 0.0 - - candidate_vectors: List[List[float]] = [] - candidate_vector = list(candidate.get("deep_vector", []) or []) - if candidate_vector: - candidate_vectors.append(candidate_vector) - - candidate_path = str(candidate.get("path", "") or "") - candidate_duration = float(candidate.get("duration", 0.0) or 0.0) - windows = set(ROLE_SEGMENT_SETTINGS.get(role, {}).get("windows", set()) or set()) - if candidate_path and candidate_duration > max(windows or {4.0}) * 1.5: - segment_bank = self._build_candidate_segment_bank(candidate_path, windows, duration_limit=min(max(candidate_duration, 0.0), 32.0)) - candidate_vectors.extend( - list(segment.get("vector", []) or []) - for segment in segment_bank - if segment.get("vector") - ) - - if not candidate_vectors: - return 0.0 - - matrix = self._cosine_matrix(candidate_vectors, role_vectors) - if matrix.size == 0: - return 0.0 - best_per_candidate = matrix.max(axis=1).tolist() - best_per_candidate.sort(reverse=True) - top = best_per_candidate[: min(3, len(best_per_candidate))] - return float(sum(top) / len(top)) - - def _vector_store_role_score(self, role: str, candidate: Dict[str, Any], reference: Dict[str, Any]) -> float: - entry = self._vector_store_entry(candidate) - if not entry: - return 0.5 - - entry_type = str(entry.get("type", "") or "").lower() - duration = float(entry.get("duration_estimate", candidate.get("duration", 0.0)) or 0.0) - tags = [str(tag).lower() for tag in entry.get("tags", []) if tag] - file_name = str(candidate.get("file_name", entry.get("filename", "")) or "").lower() - - type_score = 0.6 if not entry_type else (1.0 if entry_type in ROLE_VECTOR_TYPES.get(role, set()) else 0.35) - duration_score = self._duration_score(role, duration, file_name) - tag_score = self._naming_score(role, " ".join(tags + [file_name])) - tempo_score = self._tempo_score(float(entry.get("bpm", candidate.get("tempo", 0.0)) or 0.0), float(reference.get("tempo", 0.0) or 0.0)) - score = type_score * 0.34 + duration_score * 0.28 + tag_score * 0.26 + tempo_score * 0.12 - if role == 'crash_fx' and any(marker in file_name for marker in ['top loop', 'top loops', 'hat', 'snare']): - score *= 0.25 - return max(0.0, min(1.0, score)) - - def _role_score( - self, - role: str, - reference: Dict[str, Any], - candidate: Dict[str, Any], - cosine_score: float, - segment_score: float = 0.0, - catalog_score: float = 0.5, - ) -> float: - if segment_score > 0: - if role in {'kick', 'snare', 'hat', 'crash_fx', 'fill_fx', 'snare_roll', 'vocal_shot'}: - cosine_score = (float(cosine_score) * 0.28) + (float(segment_score) * 0.72) - elif role in {'bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop', 'atmos_fx'}: - cosine_score = (float(cosine_score) * 0.42) + (float(segment_score) * 0.58) - else: - cosine_score = (float(cosine_score) * 0.5) + (float(segment_score) * 0.5) - tempo_score = self._tempo_score(float(candidate.get("tempo", 0.0)), float(reference.get("tempo", 0.0))) - key_distance = _key_distance(reference.get("key"), candidate.get("key")) - key_score = max(0.0, 1.0 - (key_distance / 6.0)) - duration = float(candidate.get("duration", 0.0)) - onset = float(candidate.get("onset_mean", 0.0)) - rms = float(candidate.get("rms_mean", 0.0)) - file_name = str(candidate.get("file_name", "") or "").lower() - duration_score = self._duration_score(role, duration, file_name) - naming_score = self._naming_score(role, file_name) - spectral_score = self._spectral_role_score(role, candidate) - - if role in ['kick', 'snare', 'hat']: - base_score = ( - cosine_score * 0.18 + - tempo_score * 0.10 + - min(1.0, onset / 4.0) * 0.20 + - duration_score * 0.22 + - naming_score * 0.18 + - spectral_score * 0.12 - ) - elif role == 'bass_loop': - base_score = ( - cosine_score * 0.24 + - tempo_score * 0.20 + - key_score * 0.20 + - duration_score * 0.16 + - min(1.0, rms / 0.5) * 0.08 + - spectral_score * 0.12 - ) - elif role in ['perc_loop', 'top_loop']: - base_score = ( - cosine_score * 0.24 + - tempo_score * 0.26 + - key_score * 0.06 + - duration_score * 0.16 + - min(1.0, onset / 3.5) * 0.16 + - spectral_score * 0.12 - ) - elif role == 'synth_loop': - base_score = ( - cosine_score * 0.24 + - tempo_score * 0.16 + - key_score * 0.22 + - duration_score * 0.16 + - naming_score * 0.10 + - spectral_score * 0.12 - ) - elif role == 'vocal_loop': - base_score = ( - cosine_score * 0.26 + - tempo_score * 0.20 + - key_score * 0.06 + - duration_score * 0.18 + - naming_score * 0.18 + - spectral_score * 0.12 - ) - elif role == 'crash_fx': - base_score = ( - cosine_score * 0.14 + - tempo_score * 0.06 + - duration_score * 0.28 + - naming_score * 0.32 + - min(1.0, onset / 3.0) * 0.08 + - spectral_score * 0.12 - ) - elif role == 'fill_fx': - base_score = ( - cosine_score * 0.16 + - tempo_score * 0.16 + - duration_score * 0.22 + - naming_score * 0.22 + - min(1.0, onset / 3.0) * 0.12 + - spectral_score * 0.12 - ) - elif role == 'snare_roll': - base_score = ( - cosine_score * 0.14 + - tempo_score * 0.12 + - duration_score * 0.20 + - naming_score * 0.28 + - min(1.0, onset / 2.5) * 0.14 + - spectral_score * 0.12 - ) - elif role == 'atmos_fx': - base_score = ( - cosine_score * 0.28 + - tempo_score * 0.06 + - key_score * 0.16 + - duration_score * 0.22 + - naming_score * 0.16 + - spectral_score * 0.12 - ) - elif role == 'vocal_shot': - base_score = ( - cosine_score * 0.20 + - tempo_score * 0.10 + - key_score * 0.12 + - duration_score * 0.20 + - naming_score * 0.26 + - spectral_score * 0.12 - ) - else: - base_score = cosine_score * 0.5 + tempo_score * 0.3 + key_score * 0.2 - - return float(base_score) * (0.82 + (0.24 * float(catalog_score))) - - def _spectral_role_score(self, role: str, candidate: Dict[str, Any]) -> float: - """Score candidate based on spectral characteristics for the role.""" - centroid = float(candidate.get("spectral_centroid", 0.0)) - rolloff = float(candidate.get("spectral_rolloff", 0.0)) - rms_std = float(candidate.get("rms_std", 0.0)) - onset_mean = float(candidate.get("onset_mean", 0.0)) - rms_mean = float(candidate.get("rms_mean", 0.0)) - - # Compute spectral spread indicator - rms_spread = min(1.0, rms_std / max(0.01, rms_mean)) if rms_mean > 0 else 0.5 - - # Transient score based on onset - transient_score = min(1.0, onset_mean / 3.0) - - # Get expected signature for role - sig = SPECTRAL_ROLE_SIGNATURES.get(role) - if not sig: - return 0.5 - - score = 0.0 - - # Centroid match - centroid_min, centroid_max = sig.get('centroid_range', (0, 20000)) - if centroid_min <= centroid <= centroid_max: - score += 0.25 - else: - # Partial score for being close - dist = min(abs(centroid - centroid_min), abs(centroid - centroid_max)) - score += 0.25 * math.exp(-dist / 2000) - - # Rolloff match - rolloff_min, rolloff_max = sig.get('rolloff_range', (0, 20000)) - if rolloff_min <= rolloff <= rolloff_max: - score += 0.25 - else: - dist = min(abs(rolloff - rolloff_min), abs(rolloff - rolloff_max)) - score += 0.25 * math.exp(-dist / 3000) - - # RMS spread match (for one-shots vs loops) - spread_min, spread_max = sig.get('rms_spread', (0.0, 1.0)) - if spread_min <= rms_spread <= spread_max: - score += 0.25 - else: - dist = min(abs(rms_spread - spread_min), abs(rms_spread - spread_max)) - score += 0.25 * math.exp(-dist / 0.3) - - # Transient score match - trans_min, trans_max = sig.get('transient_score', (0.0, 1.0)) - if trans_min <= transient_score <= trans_max: - score += 0.25 - else: - dist = min(abs(transient_score - trans_min), abs(transient_score - trans_max)) - score += 0.25 * math.exp(-dist / 0.3) - - return min(1.0, max(0.0, score)) - - def _duration_score(self, role: str, duration: float, file_name: str) -> float: - """Improved duration scoring with better one-shot vs loop detection.""" - file_lower = file_name.lower() - - # One-shot roles: kick, snare/clap, hat - if role in ['kick', 'snare', 'hat']: - # Ideal one-shot duration: 0.1 - 1.5 seconds - is_explicit_loop = 'loop' in file_lower or 'looped' in file_lower - is_explicit_shot = 'shot' in file_lower or 'one shot' in file_lower or 'oneshot' in file_lower - - if is_explicit_shot and duration < 3.0: - return 1.0 - if is_explicit_loop: - return 0.35 - - # Duration-based scoring for one-shots - if duration < 0.1: - return 0.4 # Too short, probably artifact - if duration < 2.0: - # Sweet spot for one-shots - peak = 0.5 if role == 'kick' else (0.8 if role == 'hat' else 0.6) - score = math.exp(-abs(duration - peak) / 1.0) - return max(0.0, min(1.0, score)) - if duration < 4.0: - # Could be a roll or extended hit - return 0.5 if 'roll' in file_lower else 0.3 - return 0.2 # Too long for one-shot - - # Loop roles: bass, perc, top, synth, vocal - if role in ['bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop']: - is_explicit_loop = 'loop' in file_lower or 'looped' in file_lower - is_explicit_shot = 'shot' in file_lower or 'one shot' in file_lower or 'oneshot' in file_lower - - if is_explicit_shot: - return 0.25 # One-shot marked as loop role - - # Ideal loop duration: 2 - 16 seconds (typically 4 or 8 bars) - if duration < 0.5: - return 0.2 # Too short for a proper loop - if duration < 2.0: - # Short loop, acceptable but not ideal - base_score = duration / 2.0 - if is_explicit_loop: - base_score += 0.2 - return min(1.0, base_score) - if duration < 12.0: - # Sweet spot for loops (2-8 bars typically) - score = min(1.0, duration / 6.0) - if is_explicit_loop: - score = min(1.0, score + 0.15) - return score - if duration < 20.0: - # Longer loop, still acceptable - return 0.75 if is_explicit_loop else 0.6 - return 0.5 # Very long loop - - # FX roles - if role == 'crash_fx': - # Crashes: 0.5 - 4 seconds - if any(marker in file_lower for marker in ['loop', 'top', 'hat', 'snare']): - return 0.15 - if duration < 0.3: - return 0.3 - if duration < 5.0: - return math.exp(-abs(duration - 2.0) / 2.5) - return 0.4 - - if role in ['fill_fx', 'snare_roll']: - # Fills/rolls: 1 - 8 seconds - if duration < 0.5: - return 0.3 - if duration < 8.0: - return math.exp(-abs(duration - 4.0) / 3.0) - return 0.5 - - if role == 'atmos_fx': - # Atmos: longer, sustained sounds - if duration < 2.0: - return 0.4 - if duration < 30.0: - return min(1.0, duration / 12.0) - return 0.8 - - if role == 'vocal_shot': - # Vocal shots: short one-shots - if duration < 0.2: - return 0.5 - if duration < 2.0: - return math.exp(-abs(duration - 0.8) / 1.2) - if duration < 4.0: - return 0.4 - return 0.25 - - return 0.5 - - def _naming_score(self, role: str, file_name: str) -> float: - if role == 'kick': - if 'loop' in file_name: - return 0.45 - return 1.0 if 'kick' in file_name else 0.7 - if role == 'snare': - if 'roll' in file_name: - return 0.4 - if 'clap' in file_name or 'snare' in file_name: - return 1.0 - return 0.7 - if role == 'hat': - if 'loop' in file_name: - return 0.7 - if 'closed' in file_name or 'hat' in file_name: - return 1.0 - return 0.75 - if role == 'vocal_loop': - if 'vocal' in file_name or 'vox' in file_name: - return 1.0 - return 0.7 - if role == 'top_loop': - if 'top' in file_name or 'full drum' in file_name: - return 1.0 - if 'perc' in file_name: - return 0.58 - return 0.85 if 'loop' in file_name else 0.65 - if role in ['bass_loop', 'perc_loop', 'synth_loop']: - return 1.0 if 'loop' in file_name else 0.72 - if role == 'crash_fx': - if 'crash' in file_name: - return 1.0 - if 'impact' in file_name: - return 0.9 - if any(marker in file_name for marker in ['top loop', 'top loops', 'closed hat', 'open hat', 'snare', 'roll']): - return 0.2 - return 0.65 - if role == 'fill_fx': - if 'fill' in file_name: - return 1.0 - if 'tom' in file_name or 'roll' in file_name: - return 0.84 - return 0.62 - if role == 'snare_roll': - if 'roll' in file_name: - return 1.0 - if 'snare' in file_name or 'fill' in file_name: - return 0.82 - return 0.55 - if role == 'atmos_fx': - if 'atmos' in file_name: - return 1.0 - if 'drone' in file_name or 'noise' in file_name: - return 0.82 - return 0.64 - if role == 'vocal_shot': - if 'vocal' in file_name or 'importante' in file_name: - return 1.0 - if 'shot' in file_name: - return 0.88 - return 0.64 - return 0.8 - - def _candidate_path(self, item: Optional[Dict[str, Any]]) -> str: - if not isinstance(item, dict): - return "" - return str(item.get("path", "") or "").strip().lower() - - def _candidate_family(self, item: Optional[Dict[str, Any]]) -> str: - if not isinstance(item, dict): - return "" - - file_name = str(item.get("file_name", "") or Path(str(item.get("path", "") or "")).name).strip().lower() - stem = Path(file_name).stem.lower() - if not stem: - return "" - - markers = [ - " - kick", " - snare", " - clap", " - closed hat", " - open hat", " - hat", - " - bass loop", " - percussion loop", " - percussion", " - perc loop", - " - top loop", " - synth loop", " - vocal loop", " - vocal one shot", - " - fill", " - snareroll", " - snare roll", " - crash", " - atmos", - ] - for marker in markers: - if marker in stem: - return stem.split(marker, 1)[0].strip() - - if " - " in stem: - return " - ".join(part.strip() for part in stem.split(" - ")[:2] if part.strip()) - if "_" in stem: - return "_".join(stem.split("_")[:2]).strip("_") - - words = stem.split() - return " ".join(words[:2]) if words else stem - - def _remember_candidate(self, item: Optional[Dict[str, Any]]) -> None: - path_key = self._candidate_path(item) - family_key = self._candidate_family(item) - if path_key: - self._recent_paths.append(path_key) - if hasattr(self, '_generation_path_usage'): - self._generation_path_usage[path_key] += 1 - if family_key: - self._recent_families.append(family_key) - # Track usage count for progressive penalty - self._family_usage_count[family_key] = self._family_usage_count.get(family_key, 0) + 1 - if hasattr(self, '_generation_family_usage'): - self._generation_family_usage[family_key] += 1 - - def _get_family_penalty(self, family_key: str) -> float: - """Calculate progressive penalty for repeated families.""" - if not family_key: - return 1.0 - - if family_key in self._recent_families: - return 0.08 - - usage_count = self._family_usage_count.get(family_key, 0) - if usage_count == 0: - return 1.0 - if usage_count == 1: - return 0.45 - if usage_count == 2: - return 0.22 - if usage_count >= 3: - return 0.08 - - return 1.0 - - def _get_cross_generation_family_penalty(self, family_key: str) -> float: - """Penaliza familias usadas en generaciones previas de referencia.""" - if not family_key: - return 1.0 - usage_count = int(_cross_generation_reference_family_memory.get(family_key, 0) or 0) - if usage_count <= 0: - return 1.0 - if usage_count == 1: - return 0.55 - if usage_count == 2: - return 0.30 - if usage_count >= 3: - return 0.08 - return max(0.08, 1.0 - (usage_count * 0.18)) - - def _get_cross_generation_path_penalty(self, path_key: str) -> float: - """Penaliza paths usados en generaciones previas de referencia.""" - if not path_key: - return 1.0 - usage_count = int(_cross_generation_reference_path_memory.get(path_key, 0) or 0) - if usage_count <= 0: - return 1.0 - if usage_count == 1: - return 0.40 - if usage_count >= 2: - return 0.15 - return max(0.25, 1.0 - (usage_count * 0.20)) - - def _select_candidate(self, role: str, items: List[Dict[str, Any]], rng: random.Random, - section_kind: str = "", section_energy: float = 0.5) -> Optional[Dict[str, Any]]: - if not items: - return None - - pool_sizes = { - "kick": 16, - "snare": 16, - "hat": 18, - "bass_loop": 14, - "perc_loop": 16, - "top_loop": 14, - "synth_loop": 14, - "vocal_loop": 12, - "crash_fx": 10, - "fill_fx": 12, - "snare_roll": 10, - "atmos_fx": 10, - "vocal_shot": 12, - } - pool_size = min(pool_sizes.get(role, 10), len(items)) - candidates = list(items[:pool_size]) - - section_bonus = { - 'kick': {'intro': 0.04, 'verse': 0.08, 'build': 0.10, 'drop': 0.14, 'break': -0.06, 'outro': 0.02}, - 'snare': {'intro': -0.08, 'verse': 0.06, 'build': 0.10, 'drop': 0.12, 'break': 0.04, 'outro': -0.06}, - 'hat': {'intro': 0.06, 'verse': 0.08, 'build': 0.12, 'drop': 0.10, 'break': -0.04, 'outro': 0.02}, - 'bass_loop': {'intro': -0.10, 'verse': 0.08, 'build': 0.12, 'drop': 0.18, 'break': -0.08, 'outro': -0.04}, - 'perc_loop': {'intro': 0.02, 'verse': 0.08, 'build': 0.14, 'drop': 0.16, 'break': 0.04, 'outro': 0.00}, - 'top_loop': {'intro': 0.04, 'verse': 0.08, 'build': 0.14, 'drop': 0.16, 'break': 0.02, 'outro': 0.00}, - 'synth_loop': {'intro': 0.04, 'verse': 0.06, 'build': 0.12, 'drop': 0.18, 'break': 0.10, 'outro': 0.02}, - 'vocal_loop': {'intro': -0.04, 'verse': 0.12, 'build': 0.08, 'drop': 0.14, 'break': 0.08, 'outro': -0.02}, - 'crash_fx': {'intro': 0.08, 'verse': 0.02, 'build': 0.14, 'drop': 0.08, 'break': -0.04, 'outro': 0.08}, - 'fill_fx': {'intro': 0.02, 'verse': 0.04, 'build': 0.16, 'drop': 0.10, 'break': 0.08, 'outro': 0.02}, - 'snare_roll': {'intro': -0.06, 'verse': 0.02, 'build': 0.22, 'drop': 0.12, 'break': 0.04, 'outro': -0.08}, - 'atmos_fx': {'intro': 0.20, 'verse': 0.04, 'build': 0.02, 'drop': -0.04, 'break': 0.20, 'outro': 0.16}, - 'vocal_shot': {'intro': -0.04, 'verse': 0.08, 'build': 0.10, 'drop': 0.14, 'break': 0.06, 'outro': -0.02}, - } - - weighted: List[Tuple[float, Dict[str, Any]]] = [] - - for index, item in enumerate(candidates): - score = max(0.001, float(item.get("score", 0.001))) - rank_penalty = max(0.30, 1.0 - (index * 0.055)) - - passes_validation, validation_mod, validation_reason = self._validate_role_requirement(role, item) - if not passes_validation: - continue - - score *= validation_mod - - path_key = self._candidate_path(item) - path_penalty = 0.12 if path_key in self._recent_paths else 1.0 - - family_key = self._candidate_family(item) - family_penalty = self._get_family_penalty(family_key) - cross_family_penalty = self._get_cross_generation_family_penalty(family_key) - cross_path_penalty = self._get_cross_generation_path_penalty(path_key) - - section_bonus_val = section_bonus.get(role.lower(), {}).get(section_kind.lower(), 0.0) - if section_kind.lower() in {'drop', 'build'} and section_energy > 0.7: - section_bonus_val *= 1.2 - elif section_kind.lower() in {'break', 'intro'} and section_energy < 0.4: - section_bonus_val *= 1.2 - - energy_mod = 1.0 - rms = float(item.get("rms_mean", 0.0) or 0.0) - if role.lower() in {"kick", "snare", "bass_loop"}: - if rms > 0.08: - energy_mod = min(1.15, 1.0 + (rms - 0.08) * 2.0) - elif rms < 0.03 and section_kind.lower() not in {"intro", "break"}: - energy_mod = 0.85 - - role_randomness = 0.88 + (rng.random() * 0.24) - - weight = ( - (score ** 1.7) - * rank_penalty - * path_penalty - * family_penalty - * cross_family_penalty - * cross_path_penalty - * role_randomness - * energy_mod - ) - - if section_bonus_val > 0: - weight *= (1.0 + section_bonus_val) - elif section_bonus_val < 0: - weight *= (1.0 + section_bonus_val * 0.5) - - weighted.append((max(0.001, weight), item)) - - if not weighted: - weighted = [(max(0.001, float(item.get("score", 0.001))), item) for item in candidates] - - total = sum(weight for weight, _ in weighted) - if total <= 0: - return candidates[0] if candidates else None - - pivot = rng.random() * total - running = 0.0 - for weight, item in weighted: - running += weight - if pivot <= running: - return item - - return weighted[0][1] - - def _select_distinct_candidate( - self, - role: str, - items: List[Dict[str, Any]], - rng: random.Random, - used_paths: set, - used_families: set, - section_kind: str = "", - section_energy: float = 0.5, - ) -> Optional[Dict[str, Any]]: - if not items: - return None - - filtered = [ - item for item in items - if self._candidate_path(item) not in used_paths - ] - - family_filtered = [ - item for item in filtered - if self._candidate_family(item) not in used_families - ] - - pool = family_filtered if family_filtered else filtered if filtered else items - - selected = self._select_candidate(role, pool, rng, section_kind, section_energy) - selected_path = self._candidate_path(selected) - selected_family = self._candidate_family(selected) - - if selected_path: - used_paths.add(selected_path) - if selected_family: - used_families.add(selected_family) - - self._remember_candidate(selected) - return selected - - def reset_family_tracking(self) -> None: - """Reset family usage tracking for a new generation.""" - self._family_usage_count.clear() - self._recent_families.clear() - self._recent_paths.clear() - - def start_generation_tracking(self) -> None: - """Inicia tracking de paths/familias para una generación nueva.""" - self._generation_family_usage = defaultdict(int) - self._generation_path_usage = defaultdict(int) - - def end_generation_tracking(self) -> None: - """Actualiza memoria cross-generation de la ruta de referencia.""" - for key in list(_cross_generation_reference_family_memory.keys()): - _cross_generation_reference_family_memory[key] = max(0, _cross_generation_reference_family_memory[key] - 1) - for key in list(_cross_generation_reference_path_memory.keys()): - _cross_generation_reference_path_memory[key] = max(0, _cross_generation_reference_path_memory[key] - 1) - - for family, count in dict(getattr(self, '_generation_family_usage', {})).items(): - if family: - _cross_generation_reference_family_memory[family] += int(count) - for path_key, count in dict(getattr(self, '_generation_path_usage', {})).items(): - if path_key: - _cross_generation_reference_path_memory[path_key] += int(count) - - for key in list(_cross_generation_reference_family_memory.keys()): - if _cross_generation_reference_family_memory[key] <= 0: - del _cross_generation_reference_family_memory[key] - for key in list(_cross_generation_reference_path_memory.keys()): - if _cross_generation_reference_path_memory[key] <= 0: - del _cross_generation_reference_path_memory[key] - - if hasattr(self, '_generation_family_usage'): - delattr(self, '_generation_family_usage') - if hasattr(self, '_generation_path_usage'): - delattr(self, '_generation_path_usage') - - def reset_cross_generation_tracking(self) -> None: - """Resetea la memoria de diversidad entre generaciones para referencia.""" - _cross_generation_reference_family_memory.clear() - _cross_generation_reference_path_memory.clear() - - def reset_recent_sample_diversity_memory(self) -> None: - """Resetea la memoria de diversidad de samples recientes por rol.""" - global _recent_sample_diversity_memory - _recent_sample_diversity_memory.clear() - - def sync_recent_memory_from_selector(self) -> None: - """Sync recent sample diversity memory from sample_selector module.""" - global _recent_sample_diversity_memory - try: - from .sample_selector import _recent_sample_diversity_memory as selector_memory - for role, paths in selector_memory.items(): - if role not in _recent_sample_diversity_memory: - _recent_sample_diversity_memory[role] = [] - for path in paths: - if path not in _recent_sample_diversity_memory[role]: - _recent_sample_diversity_memory[role].append(path) - except ImportError: - pass - - def get_recent_sample_diversity_state(self) -> Dict[str, List[str]]: - """Get copy of recent sample diversity memory.""" - return {role: list(paths) for role, paths in _recent_sample_diversity_memory.items()} - - def match_assets(self, reference_path: str) -> Dict[str, Any]: - reference = self.analyze_reference(reference_path) - reference_sections = self.detect_reference_sections(reference_path) - segment_bank = self._build_reference_segment_bank(reference_path, reference, reference_sections) - assets = self._list_assets() - matches: Dict[str, List[Dict[str, Any]]] = {} - role_segments = { - role: self._select_role_reference_segments(role, reference, segment_bank) - for role in assets.keys() - } - rerank_limits = { - "kick": 14, - "snare": 14, - "hat": 16, - "bass_loop": 12, - "perc_loop": 14, - "top_loop": 12, - "synth_loop": 12, - "vocal_loop": 12, - "crash_fx": 10, - "fill_fx": 10, - "snare_roll": 10, - "atmos_fx": 8, - "vocal_shot": 10, - } - - for role, files in assets.items(): - analyses: List[Dict[str, Any]] = [] - vectors: List[List[float]] = [] - for file_path in files: - try: - analysis = self.analyze_file(str(file_path), duration_limit=64.0) - except Exception: - continue - analyses.append(analysis) - vectors.append(list(analysis.get("vector", []))) - - scores = self._cosine_scores(reference.get("vector", []), vectors) - role_matches: List[Dict[str, Any]] = [] - for analysis, cosine_score in zip(analyses, scores): - catalog_score = self._vector_store_role_score(role, analysis, reference) - preliminary_score = self._role_score( - role, - reference, - analysis, - float(cosine_score), - segment_score=0.0, - catalog_score=catalog_score, - ) - role_matches.append({ - "_analysis": analysis, - "_cosine": float(cosine_score), - "_catalog": float(catalog_score), - "_preliminary": float(preliminary_score), - }) - - role_matches.sort(key=lambda item: item["_preliminary"], reverse=True) - rerank_limit = min(int(rerank_limits.get(role, 10) or 10), len(role_matches)) - - role_section_features = self._section_detector._get_role_section_features(role, reference_sections, role_segments.get(role, [])) - - finalized_matches: List[Dict[str, Any]] = [] - for index, item in enumerate(role_matches): - analysis = item["_analysis"] - cosine_score = float(item["_cosine"]) - catalog_score = float(item["_catalog"]) - segment_score = 0.0 - character_bonus = 1.0 - final_score = float(item["_preliminary"]) - - if index < rerank_limit: - segment_score = self._role_segment_similarity(role, analysis, role_segments.get(role, [])) - final_score = self._role_score( - role, - reference, - analysis, - cosine_score, - segment_score=segment_score, - catalog_score=catalog_score, - ) - - if role_section_features: - character_bonus = self._section_detector._section_character_bonus( - role, role_section_features, analysis - ) - final_score = final_score * character_bonus - - finalized_matches.append({ - "path": analysis["path"], - "file_name": analysis["file_name"], - "tempo": analysis["tempo"], - "key": analysis["key"], - "duration": analysis["duration"], - "cosine": round(float(cosine_score), 6), - "segment_score": round(float(segment_score), 6), - "catalog_score": round(float(catalog_score), 6), - "character_bonus": round(float(character_bonus), 3), - "score": round(float(final_score), 6), - }) - - finalized_matches.sort(key=lambda item: item["score"], reverse=True) - matches[role] = finalized_matches - - # Build section energy profile for generator - section_energy_profile = [] - for section in reference_sections: - features = section.get('features', {}) - section_energy_profile.append({ - 'kind': section.get('kind', 'drop'), - 'energy_mean': features.get('energy_mean', features.get('energy', 0.5)), - 'energy_peak': features.get('energy_peak', 0.5), - 'energy_slope': features.get('energy_slope', 0.0), - 'spectral_centroid_mean': features.get('spectral_centroid_mean', features.get('brightness', 0.5)), - 'spectral_centroid_std': features.get('spectral_centroid_std', 0.0), - 'onset_rate': features.get('onset_rate', features.get('onset_density', 0.5)), - 'low_energy_ratio': features.get('low_energy_ratio', 0.0), - 'high_energy_ratio': features.get('high_energy_ratio', 0.0), - 'kind_confidence': section.get('kind_confidence', 0.5), - }) - - return { - "reference": reference, - "reference_sections": reference_sections, - "segment_bank_size": len(segment_bank), - "role_segments": { - role: [ - { - "start": segment.get("start"), - "end": segment.get("end"), - "kind": segment.get("kind"), - "window_seconds": segment.get("window_seconds"), - } - for segment in items - ] - for role, items in role_segments.items() - }, - "matches": matches, - "section_energy_profile": section_energy_profile, - "device": self.device_name, - } - - def _section_offsets(self, sections: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float, float]]: - offsets: List[Tuple[Dict[str, Any], float, float]] = [] - position = 0.0 - for section in sections: - beats = float(section.get("beats", 0.0) or (float(section.get("bars", 8)) * 4.0)) - start = position - end = position + beats - offsets.append((section, start, end)) - position = end - return offsets - - def _section_energy(self, reference: Dict[str, Any], progress: float) -> float: - blocks = reference.get("blocks", []) - if not blocks: - return 0.5 - index = min(len(blocks) - 1, max(0, int(round(progress * (len(blocks) - 1))))) - return float(blocks[index].get("energy_norm", 0.5)) - - def _loop_step_beats(self, item: Optional[Dict[str, Any]], project_bpm: float, default_beats: float = 16.0) -> float: - if not item: - return default_beats - duration = float(item.get("duration", 0.0)) - source_tempo = float(item.get("tempo", 0.0)) - if duration <= 0: - return default_beats - if source_tempo > 0: - source_beats = duration * source_tempo / 60.0 - rounded = max(4.0, round(source_beats / 4.0) * 4.0) - return float(rounded) - estimated = duration * project_bpm / 60.0 - rounded = max(4.0, round(estimated / 4.0) * 4.0) - return float(rounded) - - def _detect_roles_for_segment(self, features: Dict[str, float], section_kind: str) -> List[str]: - """Detect appropriate roles for a segment based on its features and section type.""" - roles = [] - energy = features.get('energy', 0.5) - onset = features.get('onset_density', 0.5) - brightness = features.get('brightness', 0.5) - - # Drums are always present in non-intro/outro sections - if section_kind in ['drop', 'build', 'verse']: - roles.extend(['kick', 'snare', 'hat']) - - # Bass is present in high-energy sections - if section_kind in ['drop', 'build'] or energy > 0.5: - roles.append('bass_loop') - - # Percussion and top loops based on onset density - if onset > 0.4: - roles.extend(['perc_loop', 'top_loop']) - - # Synths in drops and high-brightness sections - if section_kind == 'drop' or (brightness > 0.5 and energy > 0.6): - roles.append('synth_loop') - - # Vocals in drops and verse sections - if section_kind in ['drop', 'verse']: - roles.extend(['vocal_loop', 'vocal_shot']) - - # FX based on section type - if section_kind == 'build': - roles.extend(['snare_roll', 'fill_fx', 'crash_fx']) - elif section_kind == 'break': - roles.extend(['atmos_fx', 'fill_fx']) - elif section_kind == 'intro': - roles.extend(['atmos_fx', 'crash_fx']) - elif section_kind == 'outro': - roles.extend(['atmos_fx', 'crash_fx']) - - return list(set(roles)) - - def _analyze_segment_roles(self, reference: Dict[str, Any], sections: List[Dict[str, Any]]) -> Dict[str, List[str]]: - """Analyze and return recommended roles for each section.""" - segment_roles: Dict[str, List[str]] = {} - - for i, section in enumerate(sections): - kind = str(section.get("kind", "drop")).lower() - - # Use features if available from automatic detection - features = section.get("features", { - 'energy': 0.5, - 'onset_density': 0.5, - 'brightness': 0.5, - }) - - # Estimate features from position if not available - if 'energy' not in features: - blocks = reference.get("blocks", []) - if blocks: - progress = i / max(1, len(sections) - 1) - idx = min(len(blocks) - 1, max(0, int(progress * (len(blocks) - 1)))) - features['energy'] = float(blocks[idx].get("energy_norm", 0.5)) - - roles = self._detect_roles_for_segment(features, kind) - segment_roles[f"section_{i}_{kind}"] = roles - - return segment_roles - - def detect_reference_sections(self, reference_path: str, min_section_seconds: float = 8.0) -> List[Dict[str, Any]]: - """Automatically detect sections from a reference track with richer feature extraction.""" - if librosa is None: - raise RuntimeError("librosa no está disponible") - - path = Path(reference_path) - y, sr = librosa.load(str(path), sr=22050, mono=True) - hop_length = 512 - n_fft = _adaptive_n_fft(len(y)) - - onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length) - rms = librosa.feature.rms(y=y, hop_length=hop_length)[0] - centroid = librosa.feature.spectral_centroid(y=y, sr=sr, n_fft=n_fft)[0] - - duration = float(librosa.get_duration(y=y, sr=sr)) - - sections = self._section_detector.detect_sections( - rms, onset_env, centroid, duration, min_section_seconds - ) - - tempo = float(librosa.feature.tempo(onset_envelope=onset_env, sr=sr, aggregate=np.median) or 128) - - if len(sections) < 2 and duration > min_section_seconds * 1.5: - mid = duration / 2 - energy_first_half = float(np.mean(rms[:int(len(rms)/2)])) if len(rms) > 0 else 0.5 - energy_second_half = float(np.mean(rms[int(len(rms)/2):])) if len(rms) > 1 else 0.5 - - if energy_first_half < energy_second_half * 0.8: - sections = [ - {'kind': 'intro', 'start': 0.0, 'end': mid * 0.4, 'duration': mid * 0.4, - 'bars': max(4, int(mid * 0.4 * tempo / 60 / 4)), 'features': {'energy': energy_first_half}}, - {'kind': 'build', 'start': mid * 0.4, 'end': mid, 'duration': mid * 0.6, - 'bars': max(4, int(mid * 0.6 * tempo / 60 / 4)), 'features': {'energy': (energy_first_half + energy_second_half) / 2}}, - {'kind': 'drop', 'start': mid, 'end': duration, 'duration': mid, - 'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_second_half}}, - ] - else: - sections = [ - {'kind': 'verse', 'start': 0.0, 'end': mid, 'duration': mid, - 'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_first_half}}, - {'kind': 'drop', 'start': mid, 'end': duration, 'duration': mid, - 'bars': max(4, int(mid * tempo / 60 / 4)), 'features': {'energy': energy_second_half}}, - ] - - prev_features = None - total_sections = len(sections) - for i, section in enumerate(sections): - sec_duration = section.get('duration', 8.0) - beats_per_second = tempo / 60.0 - beats = sec_duration * beats_per_second - bars = max(4, int(round(beats / 4.0))) - section['bars'] = bars - section['beats'] = bars * 4 - section['tempo'] = round(tempo, 1) - section['section_index'] = i - section['total_sections'] = total_sections - - start_time = float(section.get('start', 0.0)) - end_time = float(section.get('end', sec_duration)) - - # Compute richer section features inline (method was in wrong class) - duration_sec = end_time - start_time - frames_per_second = sr / hop_length - start_frame = int(start_time * frames_per_second) - end_frame = int(end_time * frames_per_second) - start_frame = max(0, min(start_frame, len(rms) - 1)) - end_frame = max(start_frame + 1, min(end_frame, len(rms))) - - section_rms = rms[start_frame:end_frame] if end_frame > start_frame else np.array([0.0]) - rms_max_global = float(np.max(rms)) if len(rms) > 0 else 0.01 - energy_mean = float(np.mean(section_rms)) if len(section_rms) > 0 else 0.0 - energy_peak = float(np.max(section_rms)) if len(section_rms) > 0 else 0.0 - energy_mean_norm = min(1.0, (energy_mean / max(rms_max_global, 0.001)) * 2.0) - energy_peak_norm = min(1.0, (energy_peak / max(rms_max_global, 0.001)) * 1.5) - - richer_features = { - 'energy_mean': round(energy_mean_norm, 3), - 'energy_peak': round(energy_peak_norm, 3), - 'energy_slope': 0.0, - 'spectral_centroid_mean': 0.5, - 'spectral_centroid_std': 0.0, - 'onset_rate': 0.5, - 'low_energy_ratio': 0.3, - 'high_energy_ratio': 0.3, - } - - if 'features' not in section: - section['features'] = {} - section['features'].update(richer_features) - - kind = str(section.get('kind', 'drop')).lower() - position_ratio = start_time / max(duration, 0.001) - section['features']['total_sections'] = total_sections - - # Simple confidence calculation inline - energy = section['features'].get('energy', 0.5) - onset_density = section['features'].get('onset_density', 0.5) - - # Basic confidence based on energy and position - if kind == 'intro' and position_ratio < 0.2: - confidence = 0.7 - elif kind == 'outro' and position_ratio > 0.8: - confidence = 0.7 - elif kind == 'drop' and energy > 0.6: - confidence = 0.75 - elif kind == 'build' and 0.3 < position_ratio < 0.7: - confidence = 0.65 - elif kind == 'break' and 0.4 < position_ratio < 0.8: - confidence = 0.6 - else: - confidence = 0.5 - - section['kind_confidence'] = confidence - alternatives = [] - if confidence < 0.55: - alternatives = ['drop', 'build', 'break'] - section['kind_alternatives'] = alternatives - - prev_features = section['features'] - - sections = self._validate_section_sequence(sections, duration, tempo) - - return sections - - def _validate_section_sequence(self, sections: List[Dict[str, Any]], - duration: float, tempo: float) -> List[Dict[str, Any]]: - """Validate and potentially correct section sequence for musical coherence.""" - if len(sections) < 2: - return sections - - result = [] - sequence_issues = [] - - VALID_TRANSITIONS = { - 'intro': {'verse', 'build', 'break', 'drop'}, - 'verse': {'build', 'drop', 'break', 'verse', 'outro'}, - 'build': {'drop', 'break', 'verse'}, - 'drop': {'break', 'verse', 'build', 'outro', 'drop'}, - 'break': {'build', 'drop', 'verse', 'outro'}, - 'outro': set(), - } - - PREFERRED_FIRST = {'intro', 'verse', 'build', 'break'} - PREFERRED_LAST = {'outro', 'drop', 'break'} - - for i, section in enumerate(sections): - kind = section.get('kind', 'drop') - confidence = section.get('kind_confidence', 0.5) - alternatives = section.get('kind_alternatives', []) - - section_copy = dict(section) - - if i == 0: - if kind not in PREFERRED_FIRST: - if confidence < 0.55 and alternatives: - for alt in alternatives: - if alt in PREFERRED_FIRST: - section_copy['kind'] = alt - section_copy['sequence_correction'] = 'first_section_adjusted' - section_copy['original_kind'] = kind - break - elif confidence < 0.45: - section_copy['sequence_warning'] = f'first_section_is_{kind}' - - if i == len(sections) - 1: - if kind not in PREFERRED_LAST: - if confidence < 0.55 and alternatives: - for alt in alternatives: - if alt in PREFERRED_LAST: - section_copy['kind'] = alt - section_copy['sequence_correction'] = 'last_section_adjusted' - section_copy['original_kind'] = kind - break - elif confidence < 0.45: - section_copy['sequence_warning'] = f'last_section_is_{kind}' - - if 0 < i < len(sections) - 1: - prev_kind = sections[i - 1].get('kind', 'drop') - next_kind = sections[i + 1].get('kind', 'drop') if i + 1 < len(sections) else None - - valid_prev = kind in VALID_TRANSITIONS.get(prev_kind, set()) - - if not valid_prev and confidence < 0.60: - transition_key = f'{prev_kind}_to_{kind}' - sequence_issues.append(transition_key) - - if alternatives: - for alt in alternatives: - if alt in VALID_TRANSITIONS.get(prev_kind, set()): - if next_kind is None or next_kind in VALID_TRANSITIONS.get(alt, set()): - section_copy['kind'] = alt - section_copy['sequence_correction'] = 'transition_fixed' - section_copy['original_kind'] = kind - section_copy['invalid_transition'] = transition_key - break - - if kind == 'build': - next_kind = sections[i + 1].get('kind', '') if i < len(sections) - 1 else None - if next_kind and next_kind not in ('drop', 'break', 'verse'): - next_confidence = sections[i + 1].get('kind_confidence', 0.5) - if next_confidence < 0.60: - section_copy['build_transition_warning'] = f'build_followed_by_{next_kind}' - - if kind == 'drop': - features = section.get('features', {}) - energy = features.get('energy', 0.5) - if energy < 0.50: - section_copy['drop_energy_warning'] = f'drop_has_low_energy_{energy:.2f}' - if confidence < 0.55 and alternatives: - for alt in alternatives: - if alt in {'verse', 'build'}: - section_copy['kind'] = alt - section_copy['sequence_correction'] = 'low_energy_drop_reclassified' - section_copy['original_kind'] = 'drop' - break - - result.append(section_copy) - - if sequence_issues: - result[0]['sequence_issues'] = sequence_issues[:5] - - return result - - def _get_section_variant(self, section_kind: str, section_name: str = "") -> str: - """ - Determina la variante apropiada para una sección. - - Retorna un string como 'sparse', 'dense', 'full', etc. - """ - kind_lower = section_kind.lower() - name_lower = section_name.lower() - - # Detectar variantes especiales por nombre - if 'peak' in name_lower or 'main' in name_lower: - return 'peak' - if 'minimal' in name_lower: - return 'minimal' - if 'atmos' in name_lower: - return 'atmospheric' - - # Usar defaults por tipo - return SECTION_VARIANTS.get(kind_lower, ['standard'])[0] - - def _select_variant_samples(self, - base_samples: List[Any], - role: str, - section_variant: str, - target_key: str = None, - target_bpm: float = None) -> List[Any]: - """ - Selecciona samples apropiados para una variante de sección. - - Filtra y reordena base_samples según la variante: - - 'sparse': prefiere samples más ligeros/simples - - 'dense': prefiere samples más complejos - - 'full': usa samples principales - - 'minimal': usa samples más sutiles - """ - if not base_samples: - return base_samples - - # Por defecto, retornar sin cambios - if section_variant == 'standard': - return base_samples - - variant_samples = [] - - for sample in base_samples: - # Get sample name from the match dict - if isinstance(sample, dict): - sample_name = sample.get('file_name', '') - else: - sample_name = str(sample) - - name_lower = sample_name.lower() - - # Variant sparse/minimal: buscar keywords sutiles - if section_variant in ['sparse', 'minimal', 'atmospheric', 'fading']: - if any(kw in name_lower for kw in ['light', 'soft', 'subtle', 'simple', 'minimal', 'clean', 'thin']): - variant_samples.insert(0, sample) # Prioridad alta - elif any(kw in name_lower for kw in ['heavy', 'full', 'busy', 'complex', 'big', 'thick']): - continue # Skip para variantes sutiles - else: - variant_samples.append(sample) - - # Variant dense/full/peak: buscar keywords ricos - elif section_variant in ['dense', 'full', 'peak', 'building']: - if any(kw in name_lower for kw in ['full', 'big', 'rich', 'heavy', 'peak', 'main', 'thick']): - variant_samples.insert(0, sample) # Prioridad alta - elif any(kw in name_lower for kw in ['minimal', 'subtle', 'light', 'thin']): - continue # Skip para variantes ricas - else: - variant_samples.append(sample) - - else: - variant_samples.append(sample) - - # Si no quedan samples después del filtro, usar originals - return variant_samples if variant_samples else base_samples - - def _get_variant_samples_for_section(self, - base_samples: List[Any], - role: str, - section_kind: str, - section_name: str, - target_key: str = None, - target_bpm: float = None, - max_variants: int = 3) -> Dict[str, List[Any]]: - """ - Selecciona samples DIFERENTES para diferentes secciones de un mismo rol. - - Retorna un dict mapping section_key -> list of samples. - - Para roles variante (perc, top_loop, etc.), esto retorna samples distintos - para intro/verse/build/drop/break/outro cuando es posible. - """ - # Roles que pueden tener variación real - variant_roles = ['perc', 'perc_alt', 'top_loop', 'vocal_shot', 'synth_peak', 'atmos'] - - if role not in variant_roles or not base_samples or len(base_samples) < 3: - # No hay suficiente pool para variación - return {'all': base_samples} - - section_map = {} - - # Variantes por tipo de sección - section_types = { - 'intro': ['minimal', 'sparse'], - 'verse': ['standard', 'light'], - 'build': ['building', 'adding'], - 'drop': ['full', 'peak', 'rich'], - 'break': ['sparse', 'atmospheric'], - 'outro': ['fading', 'minimal'] - } - - # Para cada sección, seleccionar samples con preferencias diferentes - section_key = f"{section_kind}_{section_name}" - - # Determinar preferencia para esta sección - variants = section_types.get(section_kind.lower(), ['standard']) - preference = variants[0] if variants else 'standard' - - # Filtrar samples según preferencia - variant_samples = [] - remaining_samples = list(base_samples) - - for sample in remaining_samples: - # Get sample name from the match dict - if isinstance(sample, dict): - sample_name = sample.get('file_name', '') - else: - sample_name = str(sample) - - name_lower = sample_name.lower() - - # Para sparse/minimal: buscar keywords ligeros - if preference in ['minimal', 'sparse', 'atmospheric']: - if any(kw in name_lower for kw in ['light', 'soft', 'subtle', 'minimal', 'clean', 'atmos']): - variant_samples.append(sample) - elif any(kw in name_lower for kw in ['heavy', 'hard', 'full', 'big']): - continue - - # Para full/peak: buscar keywords ricos - elif preference in ['full', 'peak', 'rich', 'building']: - if any(kw in name_lower for kw in ['full', 'big', 'rich', 'heavy', 'peak', 'main']): - variant_samples.append(sample) - elif any(kw in name_lower for kw in ['minimal', 'subtle']): - continue - - else: - variant_samples.append(sample) - - # Si no encontramos suficientes, usar del pool original - if len(variant_samples) < 2: - variant_samples = base_samples[:max_variants] - - section_map[section_key] = variant_samples[:max_variants] - - return section_map - - def build_arrangement_plan(self, reference_path: str, sections: List[Dict[str, Any]], - project_bpm: float, project_key: str, - variant_seed: Optional[int] = None) -> Dict[str, Any]: - # Reset family tracking for new generation - self.reset_family_tracking() - - result = self.match_assets(reference_path) - reference = result["reference"] - matches = result["matches"] - - # Auto-detect sections if not provided or enhance existing ones - if not sections: - sections = self.detect_reference_sections(reference_path) - - offsets = self._section_offsets(sections) - rng = random.Random(variant_seed if variant_seed is not None else random.SystemRandom().randint(1, 10**9)) - - # Analyze roles per segment - segment_roles = self._analyze_segment_roles(reference, sections) - - used_paths: set = set() - used_families: set = set() - selection_order = [ - "kick", - "snare", - "hat", - "bass_loop", - "perc_loop", - "top_loop", - "synth_loop", - "vocal_loop", - "crash_fx", - "fill_fx", - "snare_roll", - "atmos_fx", - "vocal_shot", - ] - selected: Dict[str, Optional[Dict[str, Any]]] = {} - for role in selection_order: - selected[role] = self._select_distinct_candidate(role, matches.get(role, []), rng, used_paths, used_families) - - perc_candidates = [ - item for item in matches.get("perc_loop", []) - if self._candidate_path(item) != self._candidate_path(selected.get("perc_loop")) - ] - perc_alt = self._select_distinct_candidate("perc_loop", perc_candidates, rng, used_paths, used_families) if perc_candidates else None - synth_candidates = [ - item for item in matches.get("synth_loop", []) - if self._candidate_path(item) != self._candidate_path(selected.get("synth_loop")) - ] - synth_alt = self._select_distinct_candidate("synth_loop", synth_candidates, rng, used_paths, used_families) if synth_candidates else None - vocal_candidates = [ - item for item in matches.get("vocal_loop", []) - if self._candidate_path(item) != self._candidate_path(selected.get("vocal_loop")) - ] - vocal_alt = self._select_distinct_candidate("vocal_loop", vocal_candidates, rng, used_paths, used_families) if vocal_candidates else None - - def add_range(target: List[Tuple[float, Dict]], start: float, end: float, step: float, offset: float = 0.0, sample: Dict = None): - if sample is None: - return - cursor = start + offset - while cursor < end - 0.01: - target.append((round(float(cursor), 3), sample)) - cursor += step - - def add_hit(target: List[Tuple[float, Dict]], position: float, sample: Dict = None): - if position >= 0.0 and sample is not None: - target.append((round(float(position), 3), sample)) - - kick_positions: List[Tuple[float, Dict]] = [] - snare_positions: List[Tuple[float, Dict]] = [] - hat_positions: List[Tuple[float, Dict]] = [] - bass_positions: List[Tuple[float, Dict]] = [] - perc_positions: List[Tuple[float, Dict]] = [] - perc_alt_positions: List[Tuple[float, Dict]] = [] - top_loop_positions: List[Tuple[float, Dict]] = [] - synth_positions: List[Tuple[float, Dict]] = [] - synth_peak_positions: List[Tuple[float, Dict]] = [] - vocal_positions: List[Tuple[float, Dict]] = [] - vocal_build_positions: List[Tuple[float, Dict]] = [] - vocal_peak_positions: List[Tuple[float, Dict]] = [] - crash_positions: List[Tuple[float, Dict]] = [] - fill_positions: List[Tuple[float, Dict]] = [] - snare_roll_positions: List[Tuple[float, Dict]] = [] - atmos_positions: List[Tuple[float, Dict]] = [] - vocal_shot_positions: List[Tuple[float, Dict]] = [] - - bass_step = self._loop_step_beats(selected.get("bass_loop"), project_bpm, 16.0) - perc_step = self._loop_step_beats(selected.get("perc_loop"), project_bpm, 16.0) - perc_alt_step = self._loop_step_beats(perc_alt, project_bpm, 8.0) - top_loop_step = self._loop_step_beats(selected.get("top_loop"), project_bpm, 8.0) - synth_step = self._loop_step_beats(selected.get("synth_loop"), project_bpm, 16.0) - vocal_step = self._loop_step_beats(selected.get("vocal_loop"), project_bpm, 8.0) - vocal_alt_step = self._loop_step_beats(vocal_alt, project_bpm, 8.0) - synth_alt_step = self._loop_step_beats(synth_alt, project_bpm, 8.0) - atmos_step = self._loop_step_beats(selected.get("atmos_fx"), project_bpm, 16.0) - - # Store section-specific samples for roles eligible for variation - section_samples: Dict[int, Dict[str, Optional[Dict[str, Any]]]] = {} - - for index, (section, start, end) in enumerate(offsets): - kind = str(section.get("kind", "drop")).lower() - section_name = str(section.get("name", "")).lower() - midpoint = (start + end) / 2.0 - progress = midpoint / max(1.0, offsets[-1][2]) - energy = self._section_energy(reference, progress) - is_peak = "peak" in section_name or energy > 0.82 - is_vocal = "vocal" in section_name - span = max(4.0, end - start) - has_next_section = index < len(offsets) - 1 - next_section = offsets[index + 1][0] if has_next_section else {} - next_kind = str(next_section.get("kind", "")).lower() - next_name = str(next_section.get("name", "")).lower() - transition_into_drop = next_kind == "drop" or "drop" in next_name or "peak" in next_name - transition_is_vocal = "vocal" in next_name - tail_hit = max(start, end - min(4.0, span / 2.0)) - roll_start = max(start, end - min(8.0, span)) - - # Apply section variation for eligible roles - section_variant = self._get_section_variant(kind, section.get('name', '')) - section_samples[index] = {} - - # Map roles to their match lists and global selections - role_match_map = { - 'perc': ('perc_loop', matches.get('perc_loop', []), selected.get('perc_loop')), - 'perc_alt': ('perc_loop', matches.get('perc_loop', []), perc_alt), - 'top_loop': ('top_loop', matches.get('top_loop', []), selected.get('top_loop')), - 'vocal_shot': ('vocal_shot', matches.get('vocal_shot', []), selected.get('vocal_shot')), - 'synth_peak': ('synth_loop', matches.get('synth_loop', []), synth_alt), - 'atmos': ('atmos_fx', matches.get('atmos_fx', []), selected.get('atmos_fx')), - } - - for var_role, (match_role, match_list, fallback_sample) in role_match_map.items(): - if var_role in SECTION_VARIATION_ROLES and match_list and section_variant != 'standard': - # Apply variant filtering with section-specific samples - section_samples_map = self._get_variant_samples_for_section( - match_list, - var_role, - kind, - section.get('name', ''), - target_key=project_key, - target_bpm=project_bpm - ) - - # Get section-specific samples for this role - section_key = f"{kind}_{section.get('name', '')}" - specific_samples = section_samples_map.get(section_key, match_list) - - # Use specific_samples for selection - samples_to_use = specific_samples if specific_samples else match_list - - if samples_to_use and samples_to_use != match_list: - # Select from section-specific samples, avoiding already used paths - section_used_paths = used_paths.copy() - section_sample = self._select_distinct_candidate( - match_role, - samples_to_use, - rng, - section_used_paths, - used_families - ) - - if section_sample: - # Get the actual file path for logging - sample_path = section_sample.get('file_path', section_sample.get('file_name', 'unknown')) - logger.debug("SECTION_VARIANT_REAL: role '%s' using %d specific samples for section '%s' (vs %d base) - selected: %s", - var_role, len(samples_to_use), section.get('name'), len(match_list), sample_path) - section_samples[index][var_role] = section_sample - else: - # Fallback to global selection - section_samples[index][var_role] = fallback_sample - else: - # No filtering applied or no samples after filter, use global - section_samples[index][var_role] = fallback_sample - else: - # Not eligible for variation or no variant, use global - section_samples[index][var_role] = fallback_sample - - # Helper to get the right sample for a role in this section - def get_sample(role: str, fallback: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: - """Get section-specific sample if available, otherwise fallback.""" - return section_samples[index].get(role, fallback) - - atmos_sample = get_sample('atmos', selected.get("atmos_fx")) - if atmos_sample and kind in {"intro", "break", "outro"}: - add_range(atmos_positions, start, end, max(8.0, atmos_step), sample=atmos_sample) - elif atmos_sample and is_vocal and span >= 8.0: - add_hit(atmos_positions, max(start, end - 8.0), sample=atmos_sample) - - if kind == 'intro': - add_range(kick_positions, start, end, 2.0 if energy < 0.55 else 1.0, sample=selected.get("kick")) - add_range(hat_positions, start, end, 1.0, 0.5, sample=selected.get("hat")) - if selected.get("top_loop") and energy > 0.5: - add_range(top_loop_positions, start + min(4.0, span / 2.0), end, top_loop_step, 0.0, sample=get_sample('top_loop', selected.get("top_loop"))) - elif kind == 'break': - add_range(kick_positions, start, end, 4.0, sample=selected.get("kick")) - add_range(snare_positions, start + 3.0, end, 4.0, sample=selected.get("snare")) - if selected.get("perc_loop"): - perc_sample = get_sample('perc_alt', perc_alt) if perc_alt else get_sample('perc', selected.get("perc_loop")) - add_range(perc_alt_positions if perc_alt else perc_positions, start, end, perc_alt_step if perc_alt else perc_step, sample=perc_sample) - if vocal_alt and (is_vocal or energy > 0.6): - add_range(vocal_build_positions, start + max(0.0, span - 8.0), end, vocal_alt_step, sample=vocal_alt) - if selected.get("fill_fx") and has_next_section: - add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx")) - if selected.get("snare_roll") and has_next_section: - add_hit(snare_roll_positions, roll_start, sample=selected.get("snare_roll")) - elif kind == 'build': - add_range(kick_positions, start, end, 1.0, sample=selected.get("kick")) - add_range(snare_positions, start + 1.0, end, 2.0, sample=selected.get("snare")) - add_range(hat_positions, start, end, 0.5, 0.5, sample=selected.get("hat")) - if selected.get("bass_loop"): - add_range(bass_positions, start, end, bass_step, sample=selected.get("bass_loop")) - if selected.get("perc_loop"): - add_range(perc_positions, start, end, perc_step, sample=get_sample('perc', selected.get("perc_loop"))) - if selected.get("top_loop"): - add_range(top_loop_positions, start + 4.0, end, top_loop_step, sample=get_sample('top_loop', selected.get("top_loop"))) - if selected.get("vocal_loop") and is_vocal: - add_range(vocal_positions, start, end, vocal_step, sample=selected.get("vocal_loop")) - if vocal_alt and (is_vocal or energy > 0.58): - add_range(vocal_build_positions, start, end, vocal_alt_step, 0.0, sample=vocal_alt) - if selected.get("synth_loop") and energy > 0.62: - add_range(synth_positions, max(start, end - max(8.0, synth_step)), end, synth_step, sample=selected.get("synth_loop")) - if selected.get("snare_roll"): - add_hit(snare_roll_positions, roll_start, sample=selected.get("snare_roll")) - if selected.get("fill_fx"): - add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx")) - if transition_into_drop and selected.get("crash_fx"): - add_hit(crash_positions, end, sample=selected.get("crash_fx")) - else: - add_range(kick_positions, start, end, 1.0, sample=selected.get("kick")) - add_range(snare_positions, start + 1.0, end, 2.0, sample=selected.get("snare")) - add_range(hat_positions, start, end, 0.5, 0.5, sample=selected.get("hat")) - if selected.get("bass_loop"): - add_range(bass_positions, start, end, bass_step, sample=selected.get("bass_loop")) - if selected.get("perc_loop"): - add_range(perc_positions, start, end, perc_step, sample=get_sample('perc', selected.get("perc_loop"))) - if selected.get("top_loop"): - add_range(top_loop_positions, start, end, top_loop_step, sample=get_sample('top_loop', selected.get("top_loop"))) - if perc_alt and ("peak" in str(section.get("name", "")).lower() or energy > 0.82): - add_range(perc_alt_positions, start, end, perc_alt_step, sample=get_sample('perc_alt', perc_alt)) - if selected.get("synth_loop") and ("drop b" in section_name or is_peak or kind == 'drop'): - add_range(synth_positions, start, end, synth_step, sample=selected.get("synth_loop")) - if synth_alt and is_peak: - add_range(synth_peak_positions, start + min(4.0, span / 4.0), end, synth_alt_step, sample=get_sample('synth_peak', synth_alt)) - if selected.get("vocal_loop") and ("drop b" in section_name or is_peak): - add_range(vocal_positions, start + 4.0, end, vocal_step, sample=selected.get("vocal_loop")) - if vocal_alt and is_peak: - add_range(vocal_peak_positions, start, end, vocal_alt_step, sample=vocal_alt) - if selected.get("crash_fx") and index > 0: - add_hit(crash_positions, start, sample=selected.get("crash_fx")) - if selected.get("fill_fx") and has_next_section and next_kind != "outro": - add_hit(fill_positions, tail_hit, sample=selected.get("fill_fx")) - - vocal_shot_sample = get_sample('vocal_shot', selected.get("vocal_shot")) - if vocal_shot_sample and (is_peak or transition_is_vocal): - add_hit(vocal_shot_positions, min(end - 1.0, start + 4.0), sample=vocal_shot_sample) - if span >= 16.0: - add_hit(vocal_shot_positions, min(end - 1.0, start + span / 2.0), sample=vocal_shot_sample) - - layers: List[Dict[str, Any]] = [] - - def add_layer(name: str, asset: Optional[Dict[str, Any]], positions: List[Tuple[float, Dict]], - color: int, volume: float): - """Add one or more layers for positions grouped by sample.""" - if not positions: - return - - # Group positions by sample - positions_by_sample: Dict[str, List[float]] = {} - sample_info: Dict[str, Dict[str, Any]] = {} - - for pos, sample in positions: - if sample is None: - continue - sample_path = sample.get("path", "") - if sample_path not in positions_by_sample: - positions_by_sample[sample_path] = [] - sample_info[sample_path] = sample - positions_by_sample[sample_path].append(pos) - - # If no asset provided but positions exist, use the first sample - if asset is None and positions_by_sample: - first_sample_path = next(iter(positions_by_sample)) - asset = sample_info[first_sample_path] - - # If all positions use the same sample (or asset is provided), create single layer - if asset and (len(positions_by_sample) == 1 or asset.get("path") in positions_by_sample): - asset_positions = positions_by_sample.get(asset.get("path", ""), [p for p, _ in positions]) - if asset_positions: - adj_vol = volume - rms = asset.get("rms_energy", 0.0) - if rms > 0.0: - adj_vol = min(1.0, volume * ((0.2 / rms) ** 0.5)) - - layers.append({ - "name": name, - "file_path": asset["path"], - "positions": sorted(set(asset_positions)), - "color": color, - "volume": round(adj_vol, 3), - "source": asset.get("file_name", ""), - }) - else: - # Multiple samples - create layers with variant names - for i, (sample_path, pos_list) in enumerate(positions_by_sample.items()): - sample = sample_info[sample_path] - variant_name = sample.get("file_name", "") - - adj_vol = volume - rms = sample.get("rms_energy", 0.0) - if rms > 0.0: - adj_vol = min(1.0, volume * ((0.2 / rms) ** 0.5)) - - # Create variant suffix based on sample characteristics - if i > 0: - layer_name = f"{name} ({variant_name[:20]})" - else: - layer_name = name - - layers.append({ - "name": layer_name, - "file_path": sample_path, - "positions": sorted(set(pos_list)), - "color": color, - "volume": round(adj_vol, 3), - "source": variant_name, - }) - - add_layer("AUDIO KICK", selected.get("kick"), kick_positions, 10, 0.86) - add_layer("AUDIO CLAP", selected.get("snare"), snare_positions, 45, 0.72) - add_layer("AUDIO HAT", selected.get("hat"), hat_positions, 5, 0.58) - add_layer("AUDIO BASS LOOP", selected.get("bass_loop"), bass_positions, 30, 0.76) - add_layer("AUDIO PERC MAIN", selected.get("perc_loop"), perc_positions, 20, 0.68) - add_layer("AUDIO PERC ALT", perc_alt, perc_alt_positions, 22, 0.62) - add_layer("AUDIO TOP LOOP", selected.get("top_loop") or perc_alt or selected.get("perc_loop"), top_loop_positions, 24, 0.52) - add_layer("AUDIO SYNTH LOOP", selected.get("synth_loop"), synth_positions, 50, 0.52) - add_layer("AUDIO SYNTH PEAK", synth_alt or selected.get("synth_loop"), synth_peak_positions, 52, 0.48) - add_layer("AUDIO VOCAL LOOP", selected.get("vocal_loop"), vocal_positions, 40, 0.6) - add_layer("AUDIO VOCAL BUILD", vocal_alt or selected.get("vocal_loop"), vocal_build_positions, 42, 0.54) - add_layer("AUDIO VOCAL PEAK", vocal_alt or selected.get("vocal_loop"), vocal_peak_positions, 43, 0.58) - add_layer("AUDIO CRASH FX", selected.get("crash_fx"), crash_positions, 26, 0.5) - add_layer("AUDIO TRANSITION FILL", selected.get("fill_fx") or selected.get("snare_roll"), fill_positions, 28, 0.56) - add_layer("AUDIO SNARE ROLL", selected.get("snare_roll"), snare_roll_positions, 27, 0.54) - add_layer("AUDIO ATMOS", selected.get("atmos_fx"), atmos_positions, 54, 0.44) - add_layer("AUDIO VOCAL SHOT", selected.get("vocal_shot"), vocal_shot_positions, 41, 0.52) - - # Compute remake quality metrics - remake_quality = self._compute_remake_quality_metrics( - sections, selected, sections - ) - - # Build section energy profile for generator - section_energy_profile = [] - for section in sections: - features = section.get('features', {}) - section_energy_profile.append({ - 'kind': section.get('kind', 'drop'), - 'energy_mean': features.get('energy_mean', features.get('energy', 0.5)), - 'energy_peak': features.get('energy_peak', 0.5), - 'energy_slope': features.get('energy_slope', 0.0), - 'spectral_centroid_mean': features.get('spectral_centroid_mean', features.get('brightness', 0.5)), - 'spectral_centroid_std': features.get('spectral_centroid_std', 0.0), - 'onset_rate': features.get('onset_rate', features.get('onset_density', 0.5)), - 'low_energy_ratio': features.get('low_energy_ratio', 0.0), - 'high_energy_ratio': features.get('high_energy_ratio', 0.0), - 'kind_confidence': section.get('kind_confidence', 0.5), - }) - - return { - "reference": { - "path": reference.get("path"), - "file_name": reference.get("file_name"), - "tempo": reference.get("tempo"), - "key": reference.get("key") or project_key, - "device": self.device_name, - "variant_seed": variant_seed, - }, - "sections": sections, - "segment_roles": segment_roles, - "layers": layers, - "matches": selected, - "section_samples": section_samples, - "section_energy_profile": section_energy_profile, - "remake_quality": remake_quality, - } - - def _compute_remake_quality_metrics( - self, - sections: List[Dict[str, Any]], - selected: Dict[str, Optional[Dict[str, Any]]], - reference_sections: List[Dict[str, Any]] - ) -> Dict[str, Any]: - """ - Compute per-section quality scores for how well selected samples match reference character. - - Metrics included: - - Energy profile similarity - - Spectral characteristic similarity - - Rhythmic density comparison - - Low-end presence matching - - High-end brightness matching - - Uses already-computed data - no new librosa calls. - """ - section_scores = [] - - energy_profile_scores = [] - spectral_similarity_scores = [] - rhythmic_density_scores = [] - low_end_presence_scores = [] - high_end_brightness_scores = [] - - for i, section in enumerate(sections): - kind = str(section.get('kind', 'drop')).lower() - features = section.get('features', {}) - section_match_score = 0.5 - weak_roles = [] - - ref_energy_mean = features.get('energy_mean', features.get('energy', 0.5)) - _ = features.get('energy_peak', ref_energy_mean) - ref_energy_slope = features.get('energy_slope', 0.0) - ref_onset_rate = features.get('onset_rate', features.get('onset_density', 0.5)) - ref_low_ratio = features.get('low_energy_ratio', 0.0) - ref_high_ratio = features.get('high_energy_ratio', 0.0) - ref_spectral_centroid = features.get('spectral_centroid_mean', features.get('brightness', 0.5)) - ref_spectral_std = features.get('spectral_centroid_std', 0.0) - - energy_profile_score = 0.5 - spectral_similarity_score = 0.5 - rhythmic_density_score = 0.5 - low_end_presence_score = 0.5 - high_end_brightness_score = 0.5 - - selected_samples_energy = [] - selected_samples_centroid = [] - selected_samples_onset = [] - selected_samples_low_energy = 0.0 - selected_samples_high_energy = 0.0 - - for role in ['kick', 'snare', 'hat', 'bass_loop', 'perc_loop', 'top_loop', 'synth_loop', 'vocal_loop', 'atmos_fx']: - sample = selected.get(role) - if sample: - rms = float(sample.get('rms_mean', sample.get('rms_energy', 0.5)) or 0.5) - centroid = float(sample.get('spectral_centroid', 5000) or 5000) - onset = float(sample.get('onset_mean', sample.get('onset_rate', 3)) or 3) - - selected_samples_energy.append(rms) - selected_samples_centroid.append(centroid) - selected_samples_onset.append(onset) - - if centroid < 300: - selected_samples_low_energy += rms - if centroid > 4000: - selected_samples_high_energy += rms - - if selected_samples_energy: - avg_energy = sum(selected_samples_energy) / len(selected_samples_energy) - energy_diff = abs(avg_energy - ref_energy_mean) - energy_profile_score = max(0.0, 1.0 - energy_diff * 2.0) - - if ref_energy_slope > 0.1: - build_roles = ['snare_roll', 'fill_fx', 'hat'] - build_energy = sum( - float(selected.get(r, {}).get('rms_mean', 0) or 0) - for r in build_roles if selected.get(r) - ) - if build_energy > 0.3: - energy_profile_score = min(1.0, energy_profile_score + 0.15) - - if selected_samples_centroid: - avg_centroid_norm = sum(selected_samples_centroid) / len(selected_samples_centroid) / 10000.0 - ref_centroid_norm = ref_spectral_centroid - centroid_diff = abs(avg_centroid_norm - ref_centroid_norm) - spectral_similarity_score = max(0.0, 1.0 - centroid_diff) - - if ref_spectral_std > 0.3: - centroid_variance = 0.0 - if len(selected_samples_centroid) > 1: - centroid_variance = float(np.std(selected_samples_centroid)) / 10000.0 - if centroid_variance > 0.1: - spectral_similarity_score = min(1.0, spectral_similarity_score + 0.1) - - if selected_samples_onset: - avg_onset_norm = sum(selected_samples_onset) / len(selected_samples_onset) / 10.0 - ref_onset_norm = ref_onset_rate - onset_diff = abs(avg_onset_norm - ref_onset_norm) - rhythmic_density_score = max(0.0, 1.0 - onset_diff) - - if ref_onset_rate > 0.5: - perc_onset = float(selected.get('perc_loop', {}).get('onset_mean', 0) or 0) - top_onset = float(selected.get('top_loop', {}).get('onset_mean', 0) or 0) - hat_onset = float(selected.get('hat', {}).get('onset_mean', 0) or 0) - if perc_onset > 3 or top_onset > 3 or hat_onset > 3: - rhythmic_density_score = min(1.0, rhythmic_density_score + 0.15) - - bass_match = selected.get('bass_loop') - kick_match = selected.get('kick') - if bass_match or kick_match: - bass_centroid = float(bass_match.get('spectral_centroid', 500) or 500) if bass_match else 500 - kick_centroid = float(kick_match.get('spectral_centroid', 300) or 300) if kick_match else 300 - low_centroid_avg = (bass_centroid + kick_centroid) / 2 - - if ref_low_ratio > 0.3: - if low_centroid_avg < 1500: - low_end_presence_score = 0.85 + (ref_low_ratio * 0.15) - elif low_centroid_avg < 2500: - low_end_presence_score = 0.65 - else: - low_end_presence_score = 0.35 - weak_roles.append('bass_loop') - else: - low_end_presence_score = 0.7 - else: - if ref_low_ratio > 0.35: - low_end_presence_score = 0.3 - weak_roles.append('bass_loop') - - hat_match = selected.get('hat') - top_match = selected.get('top_loop') - synth_match = selected.get('synth_loop') - if hat_match or top_match or synth_match: - high_centroids = [] - if hat_match: - high_centroids.append(float(hat_match.get('spectral_centroid', 6000) or 6000)) - if top_match: - high_centroids.append(float(top_match.get('spectral_centroid', 5000) or 5000)) - if synth_match: - high_centroids.append(float(synth_match.get('spectral_centroid', 4000) or 4000)) - - avg_high_centroid = sum(high_centroids) / len(high_centroids) if high_centroids else 5000 - - if ref_high_ratio > 0.25: - if avg_high_centroid > 7000: - high_end_brightness_score = 0.85 + (ref_high_ratio * 0.15) - elif avg_high_centroid > 5000: - high_end_brightness_score = 0.65 - else: - high_end_brightness_score = 0.4 - weak_roles.append('hat') - else: - high_end_brightness_score = 0.7 - else: - if ref_high_ratio > 0.3: - high_end_brightness_score = 0.35 - weak_roles.append('hat') - - if kind == 'drop': - if bass_match and ref_energy_mean > 0.6: - section_match_score += 0.08 - if hat_match and ref_onset_rate > 0.4: - section_match_score += 0.05 - elif kind == 'break': - atmos_match = selected.get('atmos_fx') - if atmos_match and ref_energy_mean < 0.45: - section_match_score += 0.10 - low_end_presence_score = min(1.0, low_end_presence_score + 0.1) - elif kind == 'build': - snare_roll_match = selected.get('snare_roll') - fill_match = selected.get('fill_fx') - if snare_roll_match and ref_energy_slope > 0.05: - section_match_score += 0.08 - rhythmic_density_score = min(1.0, rhythmic_density_score + 0.1) - if fill_match: - section_match_score += 0.05 - elif kind == 'intro': - atmos_match = selected.get('atmos_fx') - if atmos_match: - section_match_score += 0.05 - elif kind == 'outro': - atmos_match = selected.get('atmos_fx') - if atmos_match and ref_energy_mean < 0.4: - section_match_score += 0.05 - - energy_profile_scores.append(energy_profile_score) - spectral_similarity_scores.append(spectral_similarity_score) - rhythmic_density_scores.append(rhythmic_density_score) - low_end_presence_scores.append(low_end_presence_score) - high_end_brightness_scores.append(high_end_brightness_score) - - combined_score = ( - energy_profile_score * 0.20 + - spectral_similarity_score * 0.20 + - rhythmic_density_score * 0.20 + - low_end_presence_score * 0.20 + - high_end_brightness_score * 0.20 - ) - section_match_score = max(section_match_score, combined_score) - section_match_score = max(0.0, min(1.0, section_match_score)) - - section_scores.append({ - 'kind': kind, - 'score': round(section_match_score, 3), - 'weak_roles': weak_roles, - 'energy_profile_score': round(energy_profile_score, 3), - 'spectral_similarity_score': round(spectral_similarity_score, 3), - 'rhythmic_density_score': round(rhythmic_density_score, 3), - 'low_end_presence_score': round(low_end_presence_score, 3), - 'high_end_brightness_score': round(high_end_brightness_score, 3), - }) - - overall_score = sum(s['score'] for s in section_scores) / max(len(section_scores), 1) - - avg_energy_profile = sum(energy_profile_scores) / max(len(energy_profile_scores), 1) - avg_spectral = sum(spectral_similarity_scores) / max(len(spectral_similarity_scores), 1) - avg_rhythmic = sum(rhythmic_density_scores) / max(len(rhythmic_density_scores), 1) - avg_low_end = sum(low_end_presence_scores) / max(len(low_end_presence_scores), 1) - avg_high_end = sum(high_end_brightness_scores) / max(len(high_end_brightness_scores), 1) - - improvement_hints = [] - for section_score in section_scores: - for role in section_score.get('weak_roles', []): - hint = f"{section_score['kind']} section needs better {role} samples" - if hint not in improvement_hints: - improvement_hints.append(hint) - - if avg_energy_profile < 0.5: - improvement_hints.append("Overall energy profile mismatch - adjust sample dynamics") - if avg_spectral < 0.5: - improvement_hints.append("Spectral characteristics differ - check brightness/texture match") - if avg_rhythmic < 0.5: - improvement_hints.append("Rhythmic density mismatch - adjust percussive element selection") - if avg_low_end < 0.5: - improvement_hints.append("Low-end presence weak - select bass/kick with more sub energy") - if avg_high_end < 0.5: - improvement_hints.append("High-end brightness lacking - select brighter hat/top samples") - - return { - 'remake_score': round(overall_score, 3), - 'section_scores': [ - { - 'kind': s['kind'], - 'score': s['score'], - 'weak_roles': s['weak_roles'], - } - for s in section_scores - ], - 'improvement_hints': improvement_hints[:10], - 'metric_averages': { - 'energy_similarity': round(avg_energy_profile, 3), - 'spectral_similarity': round(avg_spectral, 3), - 'rhythmic_density': round(avg_rhythmic, 3), - 'low_end_match': round(avg_low_end, 3), - 'high_end_match': round(avg_high_end, 3), - }, - 'metrics_detail': { - 'energy_similarity': { - 'description': 'RMS energy distribution comparison between selected samples and reference section energy', - 'range': '0.0-1.0, higher is better', - 'weight': 0.22, - 'factors': ['average RMS match', 'energy slope for builds', 'peak energy variance'], - }, - 'spectral_similarity': { - 'description': 'Spectral centroid and variance matching', - 'range': '0.0-1.0, higher is better', - 'weight': 0.18, - 'factors': ['centroid mean match', 'centroid variance match'], - }, - 'rhythmic_density': { - 'description': 'Onset rate comparison between selected samples and reference', - 'range': '0.0-1.0, higher is better', - 'weight': 0.22, - 'factors': ['onset rate match', 'percussive element density'], - }, - 'low_end_match': { - 'description': 'Sub-bass and low frequency content presence matching', - 'range': '0.0-1.0, higher is better', - 'weight': 0.20, - 'factors': ['bass spectral centroid', 'kick spectral centroid', 'low frequency RMS'], - }, - 'high_end_match': { - 'description': 'High frequency brightness and air content matching', - 'range': '0.0-1.0, higher is better', - 'weight': 0.18, - 'factors': ['hat spectral centroid', 'top loop brightness', 'synth high frequency content'], - }, - }, - } - - -def export_segment_rag_manifest( - manifest: List[Dict[str, Any]], - output_path: Path, - format: str = "json", -) -> None: - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - - if format == "json": - output_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") - return - - cached = [item for item in manifest if item.get("cached")] - built = [item for item in manifest if not item.get("cached")] - total_cached_segments = sum(int(item.get("segments", 0) or 0) for item in cached) - total_built_segments = sum(int(item.get("segments", 0) or 0) for item in built) - - lines = [ - "# Segment RAG Index Manifest", - "", - f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}", - f"Total Files: {len(manifest)}", - "", - "## Summary", - "", - f"- Cached (reused): {len(cached)}", - f"- Built (analyzed): {len(built)}", - f"- Cached segments: {total_cached_segments}", - f"- Built segments: {total_built_segments}", - ] - - if cached: - lines.extend(["", "## Cached Files (Reused)", ""]) - for item in sorted(cached, key=lambda value: value.get("file_name", "").lower()): - lines.append(f"- **{item.get('file_name', 'unknown')}**") - lines.append(f" - Roles: {', '.join(item.get('roles', []))}") - lines.append(f" - Segments: {int(item.get('segments', 0) or 0)}") - - if built: - lines.extend(["", "## Built Files (Analyzed)", ""]) - for item in sorted(built, key=lambda value: value.get("file_name", "").lower()): - lines.append(f"- **{item.get('file_name', 'unknown')}**") - lines.append(f" - Roles: {', '.join(item.get('roles', []))}") - lines.append(f" - Segments: {int(item.get('segments', 0) or 0)}") - - output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def _get_segment_rag_status(library_dir: Path) -> Dict[str, Any]: - """ - Get status of the segment RAG cache with human-readable metadata. - - For each cache file, tries to recover metadata from: - 1. Embedded metadata in the cache file (new format) - 2. indexing_state.json lookup (backfill source) - - Returns stats about metadata coverage and human-readable names. - """ - cache_dir = library_dir / ".segment_rag" - - if not cache_dir.exists(): - return { - "cache_dir": str(cache_dir), - "cache_files": 0, - "total_segments": 0, - "status": "not_built" - } - - cache_files = list(cache_dir.glob("*.json.gz")) - total_segments = 0 - role_coverage: Dict[str, int] = defaultdict(int) - entries: List[Dict[str, Any]] = [] - state_path = cache_dir / "indexing_state.json" - state_payload: Dict[str, Any] = {} - if state_path.exists(): - try: - state_payload = json.loads(state_path.read_text(encoding="utf-8")) - except Exception: - state_payload = {} - indexed_entries = state_payload.get("indexed_entries", {}) or {} - - # Build lookup by cache_prefix for state entries - by_prefix: Dict[str, Dict[str, Any]] = {} - for entry in indexed_entries.values(): - cache_prefix = entry.get("cache_prefix") - if cache_prefix: - by_prefix[cache_prefix] = entry - - # Track metadata coverage - files_with_embedded_metadata = 0 - files_with_state_metadata = 0 - files_without_metadata = 0 - - for cache_file in cache_files: - try: - with gzip.open(cache_file, "rt", encoding="utf-8") as handle: - payload = json.load(handle) - if isinstance(payload, list): - segments = payload - metadata = {} - elif isinstance(payload, dict): - segments = payload.get("segments", []) or [] - metadata = payload.get("metadata", {}) or {} - else: - segments = [] - metadata = {} - total_segments += len(segments) - - # Extract cache prefix from filename (format: {path_key}__{fingerprint}__{windows}__{duration}.json.gz) - cache_stem = cache_file.name[:-8] if cache_file.name.endswith(".json.gz") else cache_file.stem - cache_prefix = cache_stem.rsplit("__", 1)[0] - - # Look up metadata from state file - state_entry = by_prefix.get(cache_prefix, {}) - - # Determine metadata source - has_embedded = bool(metadata) - has_state = bool(state_entry) - - if has_embedded: - files_with_embedded_metadata += 1 - elif has_state: - files_with_state_metadata += 1 - else: - files_without_metadata += 1 - - # Merge metadata: prefer embedded, fallback to state - file_name = metadata.get("file_name") or state_entry.get("file_name") or cache_file.name - file_path = metadata.get("path") or state_entry.get("path") or "" - roles = metadata.get("roles") or state_entry.get("roles") or [] - - # Determine if the name is human-readable (not just a hash) - is_hash_name = len(cache_stem.split("__")[0]) == 16 and all(c in "0123456789abcdef" for c in cache_stem.split("__")[0]) - has_human_name = file_name != cache_file.name and not is_hash_name - - for role in roles: - if role: - role_coverage[role] += len(segments) - - mtime = cache_file.stat().st_mtime - - entries.append({ - "file_name": file_name, - "path": file_path, - "segments": len(segments), - "mtime": mtime, - "cache_file": cache_file.name, - "roles": roles, - "has_embedded_metadata": has_embedded, - "has_human_readable_name": has_human_name, - }) - except Exception: - logger.debug("Failed to inspect segment cache %s", cache_file, exc_info=True) - - entries.sort(key=lambda item: item["mtime"], reverse=True) - - # Calculate cache size - cache_size_bytes = sum(f.stat().st_size for f in cache_files) - cache_size_mb = round(cache_size_bytes / (1024 * 1024), 2) - - return { - "cache_dir": str(cache_dir), - "cache_files": len(cache_files), - "total_segments": total_segments, - "role_coverage": dict(role_coverage) if role_coverage else {}, - "newest_entries": entries[:5], - "oldest_entries": entries[-5:] if len(entries) > 5 else [], - "metadata_coverage": { - "files_with_embedded_metadata": files_with_embedded_metadata, - "files_with_state_metadata": files_with_state_metadata, - "files_without_metadata": files_without_metadata, - }, - "cache_size_mb": cache_size_mb, - "indexing_complete": state_payload.get("complete", False), - "last_indexed": state_payload.get("timestamp"), - "status": "ok" - } - - -def _backfill_segment_cache_metadata(library_dir: Path, force: bool = False) -> Dict[str, Any]: - """ - Backfill metadata into existing segment cache files. - - For cache files that don't have embedded metadata, this function: - 1. Looks up the file in indexing_state.json - 2. Rewrites the cache file with metadata included - - Args: - library_dir: Path to the audio library - force: If True, rewrite all cache files even if they already have metadata - - Returns: - Dict with backfill statistics - """ - cache_dir = library_dir / ".segment_rag" - - if not cache_dir.exists(): - return { - "cache_dir": str(cache_dir), - "backfilled": 0, - "skipped": 0, - "errors": 0, - "status": "no_cache" - } - - # Load state file for metadata lookup - state_path = cache_dir / "indexing_state.json" - state_payload: Dict[str, Any] = {} - if state_path.exists(): - try: - state_payload = json.loads(state_path.read_text(encoding="utf-8")) - except Exception: - state_payload = {} - - indexed_entries = state_payload.get("indexed_entries", {}) or {} - - # Build lookup by cache_prefix - by_prefix: Dict[str, Dict[str, Any]] = {} - for entry in indexed_entries.values(): - cache_prefix = entry.get("cache_prefix") - if cache_prefix: - by_prefix[cache_prefix] = entry - - cache_files = list(cache_dir.glob("*.json.gz")) - backfilled = 0 - skipped = 0 - errors = 0 - - for cache_file in cache_files: - try: - with gzip.open(cache_file, "rt", encoding="utf-8") as handle: - payload = json.load(handle) - - # Check if already has metadata - if isinstance(payload, dict): - segments = payload.get("segments", []) or [] - metadata = payload.get("metadata", {}) or {} - if metadata and not force: - skipped += 1 - continue - elif isinstance(payload, list): - segments = payload - metadata = {} - else: - continue - - # Extract cache prefix from filename - cache_stem = cache_file.name[:-8] if cache_file.name.endswith(".json.gz") else cache_file.stem - cache_prefix = cache_stem.rsplit("__", 1)[0] - - # Look up metadata from state - state_entry = by_prefix.get(cache_prefix, {}) - file_name = metadata.get("file_name") or state_entry.get("file_name") or cache_stem - roles = metadata.get("roles") or state_entry.get("roles") or [] - path = metadata.get("path") or state_entry.get("path") or "" - - # If we found some metadata, rewrite the cache file - if file_name or roles or path: - new_metadata = { - "file_name": file_name, - "path": path, - "roles": roles, - "indexed_at": time.time(), - "backfilled": True, - } - new_payload = { - "segments": segments, - "metadata": new_metadata - } - with gzip.open(cache_file, "wt", encoding="utf-8") as handle: - json.dump(new_payload, handle) - backfilled += 1 - logger.debug("Backfilled metadata for %s", cache_file.name) - else: - skipped += 1 - - except Exception: - errors += 1 - logger.debug("Failed to backfill %s", cache_file, exc_info=True) - - return { - "cache_dir": str(cache_dir), - "cache_files": len(cache_files), - "backfilled": backfilled, - "skipped": skipped, - "errors": errors, - "status": "ok" - } diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_stem_builder.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_stem_builder.py deleted file mode 100644 index fb1a15e..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/reference_stem_builder.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -reference_stem_builder.py - Rebuild an Ableton arrangement directly from a reference track. -""" - -from __future__ import annotations - -import json -import logging -import socket -from pathlib import Path -from typing import Any, Dict, List, Tuple - -import soundfile as sf -import torch -from demucs.apply import apply_model -from demucs.pretrained import get_model - -try: - import librosa -except ImportError: # pragma: no cover - librosa = None - -try: - from reference_listener import ReferenceAudioListener -except ImportError: # pragma: no cover - from .reference_listener import ReferenceAudioListener - - -logger = logging.getLogger("ReferenceStemBuilder") - -HOST = "127.0.0.1" -PORT = 9877 -MESSAGE_TERMINATOR = b"\n" -SCRIPT_DIR = Path(__file__).resolve().parent -PACKAGE_DIR = SCRIPT_DIR.parent -PROJECT_SAMPLES_DIR = PACKAGE_DIR.parent / "librerias" / "organized_samples" -SAMPLES_DIR = str(PROJECT_SAMPLES_DIR) - -TRACK_LAYOUT = ( - ("REFERENCE FULL", 59, 0.72, True), - ("REF DRUMS", 10, 0.84, False), - ("REF BASS", 30, 0.82, False), - ("REF OTHER", 50, 0.68, False), - ("REF VOCALS", 40, 0.70, False), -) - -SECTION_BLUEPRINTS = { - "club": [ - ("INTRO DJ", 16), - ("GROOVE A", 16), - ("VOCAL BUILD", 8), - ("DROP A", 16), - ("BREAKDOWN", 8), - ("BUILD B", 8), - ("DROP B", 16), - ("PEAK", 8), - ("OUTRO DJ", 16), - ], - "standard": [ - ("INTRO", 8), - ("BUILD", 8), - ("DROP A", 16), - ("BREAK", 8), - ("DROP B", 16), - ("OUTRO", 8), - ], -} - - -class AbletonSocketClient: - def __init__(self, host: str = HOST, port: int = PORT): - self.host = host - self.port = port - - def send(self, command_type: str, params: Dict[str, Any] | None = None, timeout: float = 30.0) -> Dict[str, Any]: - payload = json.dumps({"type": command_type, "params": params or {}}, separators=(",", ":")).encode("utf-8") + MESSAGE_TERMINATOR - with socket.create_connection((self.host, self.port), timeout=timeout) as sock: - sock.sendall(payload) - data = b"" - while not data.endswith(MESSAGE_TERMINATOR): - chunk = sock.recv(65536) - if not chunk: - break - data += chunk - if not data: - raise RuntimeError(f"Sin respuesta para {command_type}") - return json.loads(data.decode("utf-8", errors="replace").strip()) - - -def _resolve_reference_profile(reference_path: Path) -> Dict[str, Any]: - listener = ReferenceAudioListener(SAMPLES_DIR) - analysis = listener.analyze_reference(str(reference_path)) - structure = "club" if analysis.get("duration", 0.0) >= 180 else "standard" - return { - "tempo": float(analysis.get("tempo", 128.0) or 128.0), - "key": str(analysis.get("key", "") or ""), - "duration": float(analysis.get("duration", 0.0) or 0.0), - "structure": structure, - "listener_device": analysis.get("device", "cpu"), - } - - -def ensure_reference_wav(reference_path: Path) -> Path: - if reference_path.suffix.lower() == ".wav": - return reference_path - - if librosa is None: - raise RuntimeError("librosa no está disponible para convertir la referencia a WAV") - - wav_path = reference_path.with_suffix(".wav") - if wav_path.exists() and wav_path.stat().st_size > 0: - return wav_path - - y, sr = librosa.load(str(reference_path), sr=44100, mono=False) - if y.ndim == 1: - y = y.reshape(1, -1) - sf.write(str(wav_path), y.T, sr, subtype="PCM_16") - return wav_path - - -def separate_stems(reference_wav: Path, output_dir: Path) -> Dict[str, Path]: - output_dir.mkdir(parents=True, exist_ok=True) - stem_root = output_dir / reference_wav.stem - expected = { - "reference": reference_wav, - "drums": stem_root / "drums.wav", - "bass": stem_root / "bass.wav", - "other": stem_root / "other.wav", - "vocals": stem_root / "vocals.wav", - } - if all(path.exists() and path.stat().st_size > 0 for path in expected.values()): - return expected - - audio, sr = sf.read(str(reference_wav), always_2d=True) - if sr != 44100: - raise RuntimeError(f"Sample rate inesperado en referencia WAV: {sr}") - - model = get_model("htdemucs") - model.cpu() - model.eval() - waveform = torch.tensor(audio.T, dtype=torch.float32) - separated = apply_model(model, waveform[None], device="cpu", progress=False)[0] - - stem_root.mkdir(parents=True, exist_ok=True) - for stem_name, tensor in zip(model.sources, separated): - stem_path = stem_root / f"{stem_name}.wav" - sf.write(str(stem_path), tensor.detach().cpu().numpy().T, sr, subtype="PCM_16") - - return expected - - -def _sections_for_structure(structure: str) -> List[Tuple[str, int]]: - return list(SECTION_BLUEPRINTS.get(structure.lower(), SECTION_BLUEPRINTS["standard"])) - - -def _create_track(client: AbletonSocketClient, name: str, color: int, volume: float) -> int: - response = client.send("create_track", {"type": "audio", "index": -1}) - if response.get("status") != "success": - raise RuntimeError(response.get("message", f"No se pudo crear {name}")) - track_index = int(response.get("result", {}).get("index")) - client.send("set_track_name", {"index": track_index, "name": name}) - client.send("set_track_color", {"index": track_index, "color": color}) - client.send("set_track_volume", {"index": track_index, "volume": volume}) - return track_index - - -def _import_full_length_audio(client: AbletonSocketClient, track_index: int, file_path: Path, name: str) -> None: - response = client.send("create_arrangement_audio_pattern", { - "track_index": track_index, - "file_path": str(file_path), - "positions": [0.0], - "name": name, - }, timeout=120.0) - if response.get("status") != "success": - raise RuntimeError(response.get("message", f"No se pudo importar {name}")) - - -def _prepare_navigation_scenes(client: AbletonSocketClient, structure: str) -> None: - sections = _sections_for_structure(structure) - session_info = client.send("get_session_info") - if session_info.get("status") != "success": - return - - scene_count = int(session_info.get("result", {}).get("num_scenes", 0) or 0) - target_count = len(sections) - - while scene_count < target_count: - create_response = client.send("create_scene", {"index": -1}) - if create_response.get("status") != "success": - break - scene_count += 1 - - while scene_count > target_count and scene_count > 1: - delete_response = client.send("delete_scene", {"index": scene_count - 1}) - if delete_response.get("status") != "success": - break - scene_count -= 1 - - for scene_index, (section_name, _) in enumerate(sections): - client.send("set_scene_name", {"index": scene_index, "name": section_name}) - - -def rebuild_project_from_reference(reference_path: Path) -> Dict[str, Any]: - reference_path = reference_path.resolve() - if not reference_path.exists(): - raise FileNotFoundError(reference_path) - - profile = _resolve_reference_profile(reference_path) - reference_wav = ensure_reference_wav(reference_path) - stems = separate_stems(reference_wav, reference_path.parent / "stems") - - client = AbletonSocketClient() - clear_response = client.send("clear_project", {"keep_tracks": 0}, timeout=120.0) - if clear_response.get("status") != "success": - raise RuntimeError(clear_response.get("message", "No se pudo limpiar el proyecto")) - - client.send("stop", {}) - client.send("set_tempo", {"tempo": round(profile["tempo"], 3)}) - client.send("show_arrangement_view", {}) - client.send("jump_to", {"time": 0}) - - created = [] - for (track_name, color, volume, muted), stem_key in zip(TRACK_LAYOUT, ("reference", "drums", "bass", "other", "vocals")): - track_index = _create_track(client, track_name, color, volume) - _import_full_length_audio(client, track_index, stems[stem_key], track_name) - if muted: - client.send("set_track_mute", {"index": track_index, "mute": True}) - created.append({ - "track_index": track_index, - "name": track_name, - "file_path": str(stems[stem_key]), - }) - - _prepare_navigation_scenes(client, profile["structure"]) - client.send("loop_selection", {"start": 0, "length": max(32.0, round(profile["duration"] * profile["tempo"] / 60.0, 3)), "enable": False}) - client.send("jump_to", {"time": 0}) - client.send("show_arrangement_view", {}) - - session_info = client.send("get_session_info") - return { - "reference": str(reference_path), - "tempo": profile["tempo"], - "key": profile["key"], - "structure": profile["structure"], - "listener_device": profile["listener_device"], - "stems": created, - "session_info": session_info.get("result", {}), - } - - -def main() -> int: - import argparse - - parser = argparse.ArgumentParser(description="Rebuild an Ableton project directly from a reference track.") - parser.add_argument("reference_path", help="Absolute or relative path to the reference audio file") - args = parser.parse_args() - - result = rebuild_project_from_reference(Path(args.reference_path)) - print(json.dumps(result, indent=2, ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/requirements.txt b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/requirements.txt deleted file mode 100644 index cf2a8b2..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/requirements.txt +++ /dev/null @@ -1,13 +0,0 @@ -# Dependencias de AbletonMCP-AI Server -# Instalar con: pip install -r requirements.txt - -mcp>=1.0.0 -# Servidor MCP FastMCP - -# Opcional: para análisis de audio avanzado -# numpy>=1.24.0 -# librosa>=0.10.0 - -# Opcional: para procesamiento con GPU AMD -# torch==2.4.1 -# torch-directml>=0.2.5 diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/retrieval_benchmark.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/retrieval_benchmark.py deleted file mode 100644 index 5224785..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/retrieval_benchmark.py +++ /dev/null @@ -1,525 +0,0 @@ -""" -retrieval_benchmark.py - Offline benchmark harness for retrieval quality inspection. - -Analyzes reference tracks and outputs top-N candidates per role to help spot -role contamination and evaluate retrieval quality. - -Usage: - python retrieval_benchmark.py --reference "path/to/track.mp3" - python retrieval_benchmark.py --reference "track1.mp3" "track2.mp3" --top-n 10 - python retrieval_benchmark.py --reference "track.mp3" --output results.json --format json - python retrieval_benchmark.py --reference "track.mp3" --output results.md --format markdown -""" - -from __future__ import annotations - -import argparse -import json -import logging -import sys -import time -from collections import defaultdict -from pathlib import Path -from typing import Any, Dict, List, Optional - -# Add parent directory to path for imports when running as script -sys.path.insert(0, str(Path(__file__).parent)) - -from reference_listener import ReferenceAudioListener, ROLE_SEGMENT_SETTINGS - -logger = logging.getLogger(__name__) - - -def _default_library_dir() -> Path: - """Get the default library directory.""" - return Path(__file__).resolve().parents[2] / "librerias" / "all_tracks" - - -def run_benchmark( - reference_paths: List[str], - library_dir: Path, - top_n: int = 10, - roles: Optional[List[str]] = None, - duration_limit: Optional[float] = None, -) -> Dict[str, Any]: - """ - Run retrieval benchmark on one or more reference tracks. - - Args: - reference_paths: List of paths to reference audio files - library_dir: Path to the sample library - top_n: Number of top candidates to show per role - roles: Optional list of specific roles to analyze - duration_limit: Optional duration limit for analysis - - Returns: - Dict containing benchmark results for each reference - """ - listener = ReferenceAudioListener(str(library_dir)) - - all_roles = list(ROLE_SEGMENT_SETTINGS.keys()) - target_roles = [r for r in (roles or all_roles) if r in all_roles] - - results = { - "benchmark_info": { - "library_dir": str(library_dir), - "top_n": top_n, - "roles": target_roles, - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), - "device": listener.device_name, - }, - "references": [], - } - - for ref_path in reference_paths: - ref_path = Path(ref_path) - if not ref_path.exists(): - logger.warning("Reference file not found: %s", ref_path) - continue - - logger.info("Analyzing reference: %s", ref_path.name) - - try: - start_time = time.time() - - # Run match_assets to get candidates per role - match_result = listener.match_assets(str(ref_path)) - reference_info = match_result.get("reference", {}) - matches = match_result.get("matches", {}) - - elapsed = time.time() - start_time - - ref_result = { - "file_name": ref_path.name, - "path": str(ref_path), - "analysis_time_seconds": round(elapsed, 2), - "reference_info": { - "tempo": reference_info.get("tempo"), - "key": reference_info.get("key"), - "duration": reference_info.get("duration"), - "rms_mean": reference_info.get("rms_mean"), - "onset_mean": reference_info.get("onset_mean"), - "spectral_centroid": reference_info.get("spectral_centroid"), - }, - "sections": [ - { - "kind": s.get("kind"), - "start": s.get("start"), - "end": s.get("end"), - "bars": s.get("bars"), - } - for s in match_result.get("reference_sections", []) - ], - "role_candidates": {}, - } - - # Process each role - for role in target_roles: - role_matches = matches.get(role, []) - top_candidates = role_matches[:top_n] - - ref_result["role_candidates"][role] = { - "total_available": len(role_matches), - "top_candidates": [ - { - "rank": i + 1, - "file_name": c.get("file_name"), - "path": c.get("path"), - "score": c.get("score"), - "cosine": c.get("cosine"), - "segment_score": c.get("segment_score"), - "catalog_score": c.get("catalog_score"), - "tempo": c.get("tempo"), - "key": c.get("key"), - "duration": c.get("duration"), - } - for i, c in enumerate(top_candidates) - ], - } - - results["references"].append(ref_result) - logger.info("Completed analysis in %.2fs", elapsed) - - except Exception as e: - logger.error("Failed to analyze %s: %s", ref_path, e, exc_info=True) - results["references"].append({ - "file_name": ref_path.name, - "path": str(ref_path), - "error": str(e), - }) - - return results - - -def analyze_role_contamination(results: Dict[str, Any]) -> Dict[str, Any]: - """ - Analyze results for potential role contamination issues. - - Returns a dict with contamination analysis: - - files appearing in multiple roles - - misnamed files (e.g., "bass" appearing in "kick" role) - - score distribution anomalies - """ - contamination = { - "cross_role_files": [], - "potential_mismatches": [], - "role_score_stats": {}, - } - - # Track files appearing in multiple roles - file_to_roles: Dict[str, List[Dict[str, Any]]] = defaultdict(list) - - for ref in results.get("references", []): - ref_name = ref.get("file_name", "unknown") - - for role, role_data in ref.get("role_candidates", {}).items(): - for candidate in role_data.get("top_candidates", []): - file_name = candidate.get("file_name", "") - if file_name: - file_to_roles[file_name].append({ - "reference": ref_name, - "role": role, - "rank": candidate.get("rank"), - "score": candidate.get("score"), - }) - - # Find files appearing in multiple roles - for file_name, appearances in file_to_roles.items(): - unique_roles = set(a["role"] for a in appearances) - if len(unique_roles) > 1: - contamination["cross_role_files"].append({ - "file_name": file_name, - "roles": list(unique_roles), - "appearances": appearances, - }) - - # Check for potential mismatches (filename suggests different role) - role_keywords = { - "kick": ["kick"], - "snare": ["snare", "clap"], - "hat": ["hat", "hihat", "hi-hat"], - "bass_loop": ["bass", "sub", "808"], - "perc_loop": ["perc", "percussion", "conga", "bongo"], - "top_loop": ["top", "drum loop", "full drum"], - "synth_loop": ["synth", "lead", "pad", "chord", "arp"], - "vocal_loop": ["vocal", "vox", "acapella"], - "crash_fx": ["crash", "cymbal", "impact"], - "fill_fx": ["fill", "transition", "tom"], - "snare_roll": ["roll", "snareroll"], - "atmos_fx": ["atmos", "drone", "ambient", "texture"], - "vocal_shot": ["shot", "vocal shot", "chop"], - } - - for ref in results.get("references", []): - for role, role_data in ref.get("role_candidates", {}).items(): - for candidate in role_data.get("top_candidates", []): - file_name = candidate.get("file_name", "").lower() - if not file_name: - continue - - # Check if file name suggests a different role - expected_keywords = role_keywords.get(role, []) - other_role_matches = [] - - for other_role, keywords in role_keywords.items(): - if other_role == role: - continue - if any(kw in file_name for kw in keywords): - other_role_matches.append(other_role) - - if other_role_matches and expected_keywords: - # File name matches another role but not this one - if not any(kw in file_name for kw in expected_keywords): - contamination["potential_mismatches"].append({ - "file_name": candidate.get("file_name"), - "assigned_role": role, - "rank": candidate.get("rank"), - "score": candidate.get("score"), - "suggested_roles": other_role_matches, - }) - - # Calculate score distribution per role - for ref in results.get("references", []): - for role, role_data in ref.get("role_candidates", {}).items(): - scores = [ - c.get("score", 0) - for c in role_data.get("top_candidates", []) - if c.get("score") is not None - ] - - if scores: - contamination["role_score_stats"][role] = { - "min": round(min(scores), 4), - "max": round(max(scores), 4), - "avg": round(sum(scores) / len(scores), 4), - "count": len(scores), - } - - return contamination - - -def format_output_json(results: Dict[str, Any]) -> str: - """Format results as JSON string.""" - return json.dumps(results, indent=2, ensure_ascii=False) - - -def format_output_markdown(results: Dict[str, Any]) -> str: - """Format results as markdown string.""" - lines = [] - - # Header - lines.append("# Retrieval Benchmark Report") - lines.append("") - lines.append(f"**Generated:** {results['benchmark_info']['timestamp']}") - lines.append(f"**Library:** `{results['benchmark_info']['library_dir']}`") - lines.append(f"**Top N:** {results['benchmark_info']['top_n']}") - lines.append(f"**Device:** {results['benchmark_info']['device']}") - lines.append("") - - # Process each reference - for ref in results.get("references", []): - lines.append(f"## Reference: {ref.get('file_name', 'unknown')}") - lines.append("") - - # Error case - if "error" in ref: - lines.append(f"**Error:** {ref['error']}") - lines.append("") - continue - - # Reference info - ref_info = ref.get("reference_info", {}) - lines.append("### Reference Analysis") - lines.append("") - lines.append("| Property | Value |") - lines.append("|----------|-------|") - lines.append(f"| Tempo | {ref_info.get('tempo', 'N/A')} BPM |") - lines.append(f"| Key | {ref_info.get('key', 'N/A')} |") - lines.append(f"| Duration | {ref_info.get('duration', 'N/A')}s |") - lines.append(f"| RMS Mean | {ref_info.get('rms_mean', 'N/A')} |") - lines.append(f"| Onset Mean | {ref_info.get('onset_mean', 'N/A')} |") - lines.append(f"| Spectral Centroid | {ref_info.get('spectral_centroid', 'N/A')} Hz |") - lines.append("") - - # Sections - sections = ref.get("sections", []) - if sections: - lines.append("### Detected Sections") - lines.append("") - lines.append("| Type | Start | End | Bars |") - lines.append("|------|-------|-----|------|") - for s in sections: - lines.append(f"| {s.get('kind', 'N/A')} | {s.get('start', 'N/A')}s | {s.get('end', 'N/A')}s | {s.get('bars', 'N/A')} |") - lines.append("") - - # Role candidates - lines.append("### Top Candidates per Role") - lines.append("") - - for role, role_data in ref.get("role_candidates", {}).items(): - total = role_data.get("total_available", 0) - lines.append(f"#### {role} ({total} available)") - lines.append("") - - candidates = role_data.get("top_candidates", []) - if not candidates: - lines.append("*No candidates found*") - lines.append("") - continue - - lines.append("| Rank | File | Score | Cosine | Seg | Catalog | Tempo | Key | Duration |") - lines.append("|------|------|-------|--------|-----|---------|-------|-----|----------|") - - for c in candidates: - lines.append( - f"| {c.get('rank', 'N/A')} | " - f"`{c.get('file_name', 'N/A')[:40]}` | " - f"{c.get('score', 0):.4f} | " - f"{c.get('cosine', 0):.4f} | " - f"{c.get('segment_score', 0):.4f} | " - f"{c.get('catalog_score', 0):.4f} | " - f"{c.get('tempo', 'N/A')} | " - f"{c.get('key', 'N/A')} | " - f"{c.get('duration', 'N/A'):.2f}s |" - ) - lines.append("") - - # Contamination analysis - if "contamination_analysis" in results: - contam = results["contamination_analysis"] - lines.append("## Role Contamination Analysis") - lines.append("") - - # Cross-role files - cross_role = contam.get("cross_role_files", []) - if cross_role: - lines.append("### Files Appearing in Multiple Roles") - lines.append("") - for item in cross_role: - lines.append(f"- **{item['file_name']}**") - lines.append(f" - Roles: {', '.join(item['roles'])}") - for app in item["appearances"]: - lines.append(f" - {app['role']}: rank {app['rank']}, score {app['score']:.4f}") - lines.append("") - - # Potential mismatches - mismatches = contam.get("potential_mismatches", []) - if mismatches: - lines.append("### Potential Role Mismatches") - lines.append("") - lines.append("Files whose names suggest a different role than assigned:") - lines.append("") - for item in mismatches: - lines.append(f"- **{item['file_name']}**") - lines.append(f" - Assigned: {item['assigned_role']} (rank {item['rank']}, score {item['score']:.4f})") - lines.append(f" - Suggested: {', '.join(item['suggested_roles'])}") - lines.append("") - - # Score stats - score_stats = contam.get("role_score_stats", {}) - if score_stats: - lines.append("### Score Distribution per Role") - lines.append("") - lines.append("| Role | Min | Max | Avg | Count |") - lines.append("|------|-----|-----|-----|-------|") - for role, stats in sorted(score_stats.items()): - lines.append( - f"| {role} | {stats['min']:.4f} | {stats['max']:.4f} | " - f"{stats['avg']:.4f} | {stats['count']} |" - ) - lines.append("") - - return "\n".join(lines) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Offline benchmark harness for retrieval quality inspection.", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - %(prog)s --reference "track.mp3" - %(prog)s --reference "track1.mp3" "track2.mp3" --top-n 15 - %(prog)s --reference "track.mp3" --output results.md --format markdown - %(prog)s --reference "track.mp3" --roles kick snare hat --top-n 20 - """, - ) - - parser.add_argument( - "--reference", "-r", - nargs="+", - required=True, - help="One or more reference audio files to analyze", - ) - parser.add_argument( - "--library-dir", - default=str(_default_library_dir()), - help="Audio library directory (default: ../librerias/all_tracks)", - ) - parser.add_argument( - "--top-n", "-n", - type=int, - default=10, - help="Number of top candidates to show per role (default: 10)", - ) - parser.add_argument( - "--roles", - nargs="*", - default=None, - help="Specific roles to analyze (default: all roles)", - ) - parser.add_argument( - "--output", "-o", - type=str, - default=None, - help="Output file path for results", - ) - parser.add_argument( - "--format", "-f", - choices=["json", "markdown", "md"], - default=None, - help="Output format (json or markdown). Auto-detected from output file extension if not specified.", - ) - parser.add_argument( - "--analyze-contamination", - action="store_true", - help="Include role contamination analysis in output", - ) - parser.add_argument( - "--verbose", "-v", - action="store_true", - help="Enable verbose logging", - ) - parser.add_argument( - "--duration-limit", - type=float, - default=None, - help="Optional duration limit for audio analysis", - ) - - args = parser.parse_args() - - # Configure logging - if args.verbose: - logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") - else: - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - - # Validate reference files - reference_paths = [] - for ref in args.reference: - ref_path = Path(ref) - if ref_path.exists(): - reference_paths.append(str(ref_path)) - else: - logger.warning("Reference file not found: %s", ref) - - if not reference_paths: - logger.error("No valid reference files provided") - return 1 - - # Run benchmark - logger.info("Running retrieval benchmark on %d reference(s)", len(reference_paths)) - - results = run_benchmark( - reference_paths=reference_paths, - library_dir=Path(args.library_dir), - top_n=args.top_n, - roles=args.roles, - duration_limit=args.duration_limit, - ) - - # Add contamination analysis if requested - if args.analyze_contamination: - logger.info("Analyzing role contamination...") - results["contamination_analysis"] = analyze_role_contamination(results) - - # Determine output format - output_format = args.format - if output_format is None and args.output: - output_format = "markdown" if args.output.endswith(".md") else "json" - output_format = output_format or "text" - - # Format output - if output_format in ("markdown", "md"): - output_text = format_output_markdown(results) - elif output_format == "json": - output_text = format_output_json(results) - else: - # Plain text summary - output_text = format_output_markdown(results) - - # Write to file or stdout - if args.output: - output_path = Path(args.output) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(output_text, encoding="utf-8") - logger.info("Results written to: %s", output_path) - else: - print(output_text) - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/role_matcher.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/role_matcher.py deleted file mode 100644 index a2a79a8..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/role_matcher.py +++ /dev/null @@ -1,469 +0,0 @@ -""" -role_matcher.py - Phase 4: Role validation and sample matching utilities - -This module provides enhanced role matching for sample selection with: -- Role validation based on audio characteristics -- Aggressive sample detection and filtering -- Logging of matching decisions -- Integration with reference_listener and sample_selector -""" - -import logging -from typing import Any, Dict, List, Optional - -logger = logging.getLogger("RoleMatcher") - - -# ============================================================================ -# CONSTANTS -# ============================================================================ - -# Valid roles for sample matching with their expected characteristics -VALID_ROLES = { - # One-shot drums - "kick": {"max_duration": 2.0, "min_onset": 0.3, "is_loop": False, "bus": "drums"}, - "snare": {"max_duration": 2.0, "min_onset": 0.25, "is_loop": False, "bus": "drums"}, - "hat": {"max_duration": 1.5, "min_onset": 0.2, "is_loop": False, "bus": "drums"}, - "clap": {"max_duration": 2.0, "min_onset": 0.25, "is_loop": False, "bus": "drums"}, - "ride": {"max_duration": 3.0, "min_onset": 0.15, "is_loop": False, "bus": "drums"}, - "perc": {"max_duration": 2.5, "min_onset": 0.2, "is_loop": False, "bus": "drums"}, - # Loops - "bass_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "bass"}, - "perc_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "drums"}, - "top_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "drums"}, - "synth_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "music"}, - "vocal_loop": {"min_duration": 2.0, "max_duration": 16.0, "is_loop": True, "bus": "vocal"}, - # FX - "crash_fx": {"max_duration": 4.0, "is_loop": False, "bus": "fx"}, - "fill_fx": {"max_duration": 8.0, "is_loop": False, "bus": "fx"}, - "snare_roll": {"max_duration": 8.0, "is_loop": False, "bus": "drums"}, - "atmos_fx": {"min_duration": 4.0, "is_loop": True, "bus": "fx"}, - "vocal_shot": {"max_duration": 3.0, "is_loop": False, "bus": "vocal"}, - # Resample layers - "resample_reverse": {"is_loop": False, "bus": "fx"}, - "resample_riser": {"is_loop": False, "bus": "fx"}, - "resample_downlifter": {"is_loop": False, "bus": "fx"}, - "resample_stutter": {"is_loop": False, "bus": "vocal"}, -} - -# Keywords that indicate aggressive/hard samples that may be misclassified -AGGRESSIVE_KEYWORDS = { - # Very aggressive kick patterns - "hard", "distorted", "industrial", "slam", "punch", "brutal", - # Potentially misclassified - "subdrop", "impact", "explosion", "destroy", -} - -# Keywords that are acceptable for aggressive genres -GENRE_APPROPRIATE_AGGRESSIVE = { - "industrial-techno", "hard-techno", "raw-techno", "psytrance", "dark-techno" -} - -# Role aliases for flexible matching -ROLE_ALIASES = { - "kick": ["kick", "bd", "bassdrum", "bass_drum"], - "snare": ["snare", "sd", "snr"], - "clap": ["clap", "cp", "handclap"], - "hat": ["hat", "hihat", "hi_hat", "hhat", "closed_hat", "hat_closed"], - "hat_open": ["open_hat", "hat_open", "ohat", "openhihat"], - "ride": ["ride", "rd", "cymbal"], - "perc": ["perc", "percussion", "percs"], - "bass_loop": ["bass_loop", "bassloop", "bass loop", "sub_bass"], - "perc_loop": ["perc_loop", "percloop", "percussion loop", "perc loop"], - "top_loop": ["top_loop", "toploop", "top loop", "full_drum"], - "synth_loop": ["synth_loop", "synthloop", "synth loop", "chord_loop", "stab"], - "vocal_loop": ["vocal_loop", "vocalloop", "vocal loop", "vox_loop", "vox"], - "crash_fx": ["crash", "crash_fx", "crashfx", "impact_fx"], - "fill_fx": ["fill", "fill_fx", "fillfx", "tom_fill", "transition"], - "snare_roll": ["snare_roll", "snareroll", "snare roll", "snr_roll"], - "atmos_fx": ["atmos", "atmos_fx", "atmosfx", "drone", "pad_fx"], - "vocal_shot": ["vocal_shot", "vocalshot", "vocal shot", "vocal_one_shot"], -} - -# Minimum score thresholds for role matching -ROLE_SCORE_THRESHOLDS = { - "kick": 0.35, - "snare": 0.32, - "hat": 0.30, - "clap": 0.32, - "bass_loop": 0.38, - "perc_loop": 0.35, - "top_loop": 0.35, - "synth_loop": 0.36, - "vocal_loop": 0.38, - "crash_fx": 0.30, - "fill_fx": 0.32, - "snare_roll": 0.30, - "atmos_fx": 0.32, - "vocal_shot": 0.34, -} - - -# ============================================================================ -# VALIDATION FUNCTIONS -# ============================================================================ - -def validate_role_for_sample( - role: str, - sample_data: Dict[str, Any], - genre: Optional[str] = None, -) -> Dict[str, Any]: - """ - Validates if a sample is appropriate for a given role. - - Args: - role: The role to validate for (e.g., 'kick', 'bass_loop') - sample_data: Sample metadata with keys like 'duration', 'onset_mean', 'file_name', 'rms_mean' - genre: Optional genre for context-aware aggressive sample handling - - Returns: - Dict with keys: - - 'valid' (bool): Whether the sample passes validation - - 'score' (float): Raw validation score (0.0-1.0) - - 'warnings' (list): List of warning messages - - 'adjusted_score' (float): Score after penalties - """ - if role not in VALID_ROLES: - return {"valid": True, "score": 0.5, "warnings": [f"Unknown role: {role}"], "adjusted_score": 0.5} - - role_config = VALID_ROLES[role] - warnings: List[str] = [] - score = 1.0 - - duration = float(sample_data.get("duration", 0.0) or 0.0) - onset = float(sample_data.get("onset_mean", 0.0) or 0.0) - file_name = str(sample_data.get("file_name", "") or "").lower() - rms = float(sample_data.get("rms_mean", 0.0) or 0.0) - - # Duration validation - if role_config.get("is_loop"): - min_dur = role_config.get("min_duration", 2.0) - max_dur = role_config.get("max_duration", 16.0) - if duration < min_dur: - warnings.append(f"Duration {duration:.1f}s too short for loop role (min {min_dur}s)") - score *= 0.7 - elif max_dur and duration > max_dur: - warnings.append(f"Duration {duration:.1f}s too long for role (max {max_dur}s)") - score *= 0.85 - else: - max_dur = role_config.get("max_duration", 3.0) - if duration > max_dur: - warnings.append(f"Duration {duration:.1f}s too long for one-shot role (max {max_dur}s)") - score *= 0.75 - if "loop" in file_name and role in ["kick", "snare", "hat", "clap"]: - warnings.append("One-shot role has 'loop' in filename") - score *= 0.65 - - # Onset validation for percussive elements - min_onset = role_config.get("min_onset", 0.0) - if min_onset > 0 and onset < min_onset: - warnings.append(f"Onset {onset:.2f} below minimum {min_onset:.2f}") - score *= 0.85 - - # Check for aggressive samples that might be misclassified - aggressive_penalty = 1.0 - is_aggressive_genre = genre and genre.lower() in GENRE_APPROPRIATE_AGGRESSIVE - - for keyword in AGGRESSIVE_KEYWORDS: - if keyword in file_name: - if not is_aggressive_genre: - aggressive_penalty *= 0.88 - warnings.append(f"Aggressive keyword '{keyword}' found for non-aggressive genre") - - score *= aggressive_penalty - - # RMS validation for certain roles - if role in ["kick", "snare", "clap"] and rms > 0.4: - warnings.append(f"High RMS {rms:.3f} for one-shot role") - score *= 0.9 - - adjusted_score = max(0.1, min(1.0, score)) - - return { - "valid": score >= 0.4, - "score": score, - "warnings": warnings, - "adjusted_score": adjusted_score, - } - - -def resolve_role_from_alias(alias: str) -> Optional[str]: - """ - Resolves a role name from various aliases. - - Args: - alias: A potential role alias (e.g., 'bd', 'hihat', 'bass loop') - - Returns: - The canonical role name or None if not found - """ - alias_lower = alias.lower().strip().replace("-", "_").replace(" ", "_") - - # Direct match - if alias_lower in VALID_ROLES: - return alias_lower - - # Check aliases - for role, aliases in ROLE_ALIASES.items(): - normalized_aliases = [a.lower().replace("-", "_").replace(" ", "_") for a in aliases] - if alias_lower in normalized_aliases: - return role - - return None - - -def get_bus_for_role(role: str) -> str: - """ - Gets the appropriate bus for a role. - - Args: - role: The role name - - Returns: - Bus name ('drums', 'bass', 'music', 'vocal', or 'fx') - """ - if role in VALID_ROLES: - return VALID_ROLES[role].get("bus", "music") - return "music" - - -# ============================================================================ -# LOGGING FUNCTIONS -# ============================================================================ - -def log_matching_decision( - role: str, - selected_sample: Optional[Dict[str, Any]], - candidates_count: int, - final_score: float, - validation_result: Optional[Dict[str, Any]] = None, -) -> None: - """ - Logs detailed matching decisions for debugging and analysis. - - Args: - role: The role being matched - selected_sample: The selected sample dict or None - candidates_count: Number of candidates considered - final_score: The final matching score - validation_result: Optional validation result dict - """ - if not selected_sample: - logger.info( - f"[MATCH] Role '{role}': No sample selected (0/{candidates_count} candidates)" - ) - return - - sample_name = selected_sample.get("file_name", "unknown") - sample_tempo = selected_sample.get("tempo", 0.0) - sample_key = selected_sample.get("key", "N/A") - sample_dur = selected_sample.get("duration", 0.0) - - log_parts = [ - f"[MATCH] Role '{role}':", - f"Sample: {sample_name}", - f"Score: {final_score:.3f}", - f"Tempo: {sample_tempo:.1f}", - f"Key: {sample_key}", - f"Duration: {sample_dur:.1f}s", - f"Candidates: {candidates_count}", - ] - - if validation_result: - warnings = validation_result.get("warnings", []) - if warnings: - log_parts.append(f"Warnings: {', '.join(warnings)}") - log_parts.append(f"Validated: {validation_result.get('valid', True)}") - - logger.info(" | ".join(log_parts)) - - -# ============================================================================ -# ENHANCEMENT FUNCTIONS -# ============================================================================ - -def enhance_sample_matching( - matches: Dict[str, List[Dict[str, Any]]], - reference: Dict[str, Any], - genre: Optional[str] = None, -) -> Dict[str, List[Dict[str, Any]]]: - """ - Enhances sample matching results with validation and filtering. - - This function takes raw matches from reference_listener and applies: - 1. Role validation based on audio characteristics - 2. Aggressive sample filtering - 3. Score adjustment based on validation results - - Args: - matches: Raw matches from reference_listener (role -> list of sample dicts) - reference: Reference track analysis data - genre: Target genre for context-aware filtering - - Returns: - Enhanced matches with validation scores and filtering applied - """ - enhanced: Dict[str, List[Dict[str, Any]]] = {} - - for role, candidates in matches.items(): - if not candidates: - enhanced[role] = [] - continue - - threshold = ROLE_SCORE_THRESHOLDS.get(role, 0.30) - enhanced_candidates: List[Dict[str, Any]] = [] - - for candidate in candidates: - # Create a copy to avoid modifying the original - enhanced_candidate = dict(candidate) - - # Validate the sample for this role - validation = validate_role_for_sample(role, candidate, genre) - enhanced_candidate["validation"] = validation - - # Apply validation penalty to the score - original_score = float(candidate.get("score", 0.0)) - adjusted_score = original_score * validation["adjusted_score"] - enhanced_candidate["adjusted_score"] = round(adjusted_score, 6) - - # Filter out samples below threshold - if adjusted_score >= threshold: - enhanced_candidates.append(enhanced_candidate) - else: - logger.debug( - f"[FILTER] Role '{role}': Filtered out '{candidate.get('file_name', 'unknown')}' " - f"(score {adjusted_score:.3f} < threshold {threshold})" - ) - - # Re-sort by adjusted score - enhanced_candidates.sort(key=lambda x: float(x.get("adjusted_score", 0.0)), reverse=True) - enhanced[role] = enhanced_candidates - - # Log summary - filtered_count = len(candidates) - len(enhanced_candidates) - if filtered_count > 0: - logger.info( - f"[ENHANCE] Role '{role}': {len(enhanced_candidates)}/{len(candidates)} candidates passed validation " - f"({filtered_count} filtered out)" - ) - - return enhanced - - -def filter_aggressive_samples( - candidates: List[Dict[str, Any]], - genre: Optional[str] = None, - strict: bool = False, -) -> List[Dict[str, Any]]: - """ - Filters out samples with aggressive keywords unless appropriate for the genre. - - Args: - candidates: List of sample candidate dicts - genre: Target genre - strict: If True, apply stricter filtering - - Returns: - Filtered list of candidates - """ - is_aggressive_genre = genre and genre.lower() in GENRE_APPROPRIATE_AGGRESSIVE - - if is_aggressive_genre: - # For aggressive genres, don't filter aggressive samples - return candidates - - filtered = [] - for candidate in candidates: - file_name = str(candidate.get("file_name", "") or "").lower() - aggressive_count = sum(1 for kw in AGGRESSIVE_KEYWORDS if kw in file_name) - - if strict and aggressive_count > 0: - continue - - # Apply penalty instead of filtering completely - if aggressive_count > 0: - penalty = 0.85 ** aggressive_count - candidate_copy = dict(candidate) - original_score = float(candidate.get("score", 0.0)) - candidate_copy["score"] = original_score * penalty - filtered.append(candidate_copy) - else: - filtered.append(candidate) - - return filtered - - -# ============================================================================ -# INTEGRATION HELPERS -# ============================================================================ - -def create_enhanced_match_report( - role: str, - selected_sample: Optional[Dict[str, Any]], - all_candidates: List[Dict[str, Any]], - validation_result: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """ - Creates a detailed report for a matching decision. - - Args: - role: The role being matched - selected_sample: The selected sample - all_candidates: All candidates that were considered - validation_result: Validation result for the selected sample - - Returns: - A dict with detailed matching report - """ - report = { - "role": role, - "selected": selected_sample is not None, - "candidates_count": len(all_candidates), - "threshold": ROLE_SCORE_THRESHOLDS.get(role, 0.30), - } - - if selected_sample: - report["selected_sample"] = { - "name": selected_sample.get("file_name"), - "path": selected_sample.get("path"), - "score": selected_sample.get("score"), - "adjusted_score": selected_sample.get("adjusted_score"), - "tempo": selected_sample.get("tempo"), - "key": selected_sample.get("key"), - "duration": selected_sample.get("duration"), - } - - if validation_result: - report["validation"] = { - "valid": validation_result.get("valid"), - "score": validation_result.get("score"), - "warnings": validation_result.get("warnings", []), - } - - return report - - -def get_role_info(role: str) -> Dict[str, Any]: - """ - Gets comprehensive information about a role. - - Args: - role: The role name - - Returns: - Dict with role information including valid samples count, thresholds, etc. - """ - if role not in VALID_ROLES: - return {"error": f"Unknown role: {role}"} - - config = VALID_ROLES[role] - aliases = ROLE_ALIASES.get(role, []) - - return { - "role": role, - "config": config, - "aliases": aliases, - "threshold": ROLE_SCORE_THRESHOLDS.get(role, 0.30), - "bus": config.get("bus", "music"), - "is_loop": config.get("is_loop", False), - } \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_index.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_index.py deleted file mode 100644 index 186b338..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_index.py +++ /dev/null @@ -1,308 +0,0 @@ -""" -sample_index.py - Índice y búsqueda de samples para AbletonMCP-AI - -Gestiona la librería de samples locales con metadatos extraídos de los nombres. -""" - -import json -import logging -from pathlib import Path -from typing import List, Dict, Any, Optional -import re - -logger = logging.getLogger("SampleIndex") - - -class SampleIndex: - """Índice de samples con búsqueda y metadatos""" - - # Categorías por palabras clave - CATEGORIES = { - 'kick': ['kick', 'bd', 'bass drum', 'kick drum'], - 'snare': ['snare', 'sd', 'snr'], - 'clap': ['clap', 'clp'], - 'hat': ['hat', 'hh', 'hihat', 'hi-hat', 'closed hat', 'open hat'], - 'perc': ['perc', 'percussion', 'conga', 'bongo', 'shaker', 'tamb', 'timb'], - 'bass': ['bass', 'bassline', 'sub', '808', ' Reese'], - 'synth': ['synth', 'lead', 'pad', 'arp', 'pluck', 'stab', 'chord'], - 'vocal': ['vocal', 'vox', 'voice', 'speech', 'talk'], - 'fx': ['fx', 'effect', 'sweep', 'riser', 'downlifter', 'impact', 'hit'], - 'loop': ['loop', 'full', 'groove'], - } - - def __init__(self, base_dir: str): - """ - Inicializa el índice de samples - - Args: - base_dir: Directorio base donde buscar samples - """ - self.base_dir = Path(base_dir) - self.samples: List[Dict[str, Any]] = [] - self.index_file = self.base_dir / ".sample_index.json" - - # Cargar o construir índice - if self.index_file.exists(): - self._load_index() - else: - self._build_index() - self._save_index() - - def _build_index(self): - """Construye el índice escaneando el directorio""" - logger.info(f"Construyendo índice de samples en: {self.base_dir}") - - extensions = {'.wav', '.aif', '.aiff', '.mp3', '.ogg'} - - for file_path in self.base_dir.rglob('*'): - if file_path.suffix.lower() in extensions: - sample_info = self._analyze_sample(file_path) - self.samples.append(sample_info) - - logger.info(f"Índice construido: {len(self.samples)} samples encontrados") - - def _analyze_sample(self, file_path: Path) -> Dict[str, Any]: - """Analiza un sample y extrae metadatos del nombre""" - name = file_path.stem - name_lower = name.lower() - - # Determinar categoría - category = self._detect_category(name_lower) - - # Extraer key del nombre - key = self._extract_key(name) - - # Extraer BPM del nombre - bpm = self._extract_bpm(name) - - return { - 'name': name, - 'path': str(file_path), - 'category': category, - 'key': key, - 'bpm': bpm, - 'size': file_path.stat().st_size if file_path.exists() else 0, - } - - def _detect_category(self, name: str) -> str: - """Detecta la categoría basada en palabras clave""" - for category, keywords in self.CATEGORIES.items(): - for keyword in keywords: - if keyword in name: - return category - return 'unknown' - - def _extract_key(self, name: str) -> Optional[str]: - """Extrae la tonalidad del nombre del archivo""" - # Patrones comunes: "Key A", "in A", "A minor", "Am", "F#m", etc. - patterns = [ - r'[_\s\-]([A-G][#b]?m?)\s*(?:minor|major)?[_\s\-]?', - r'[_\s\-]([A-G][#b]?)[_\s\-]', - r'\bin\s+([A-G][#b]?m?)\b', - r'Key\s+([A-G][#b]?m?)', - ] - - for pattern in patterns: - match = re.search(pattern, name, re.IGNORECASE) - if match: - key = match.group(1) - # Normalizar - key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') - key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') - return key - - return None - - def _extract_bpm(self, name: str) -> Optional[int]: - """Extrae el BPM del nombre del archivo""" - # Patrones: "128 BPM", "_128_", "128bpm", etc. - patterns = [ - r'[_\s\-](\d{2,3})\s*BPM', - r'[_\s\-](\d{2,3})[_\s\-]', - r'(\d{2,3})bpm', - ] - - for pattern in patterns: - match = re.search(pattern, name, re.IGNORECASE) - if match: - bpm = int(match.group(1)) - if 60 <= bpm <= 200: # Rango razonable - return bpm - - return None - - def _load_index(self): - """Carga el índice desde archivo""" - try: - with open(self.index_file, 'r') as f: - data = json.load(f) - self.samples = data.get('samples', []) - logger.info(f"Índice cargado: {len(self.samples)} samples") - except Exception as e: - logger.error(f"Error cargando índice: {e}") - self._build_index() - - def _save_index(self): - """Guarda el índice a archivo""" - try: - with open(self.index_file, 'w') as f: - json.dump({ - 'samples': self.samples, - 'base_dir': str(self.base_dir) - }, f, indent=2) - logger.info(f"Índice guardado en: {self.index_file}") - except Exception as e: - logger.error(f"Error guardando índice: {e}") - - def search(self, query: str, category: str = "", limit: int = 10) -> List[Dict[str, Any]]: - """ - Busca samples por query y/o categoría - - Args: - query: Término de búsqueda - category: Categoría específica (opcional) - limit: Número máximo de resultados - - Returns: - Lista de samples que coinciden - """ - query_lower = query.lower() - results = [] - - for sample in self.samples: - # Filtrar por categoría si se especificó - if category and sample['category'] != category.lower(): - continue - - # Buscar en nombre - name = sample['name'].lower() - if query_lower in name: - # Calcular score de relevancia - score = 0 - if query_lower == sample.get('category', ''): - score += 10 # Coincidencia exacta de categoría - if query_lower in name.split('_'): - score += 5 # Palabra completa - if name.startswith(query_lower): - score += 3 # Comienza con el término - - results.append((score, sample)) - - # Ordenar por score y limitar - results.sort(key=lambda x: x[0], reverse=True) - return [sample for _, sample in results[:limit]] - - def find_by_key(self, key: str, category: str = "", limit: int = 10) -> List[Dict[str, Any]]: - """Busca samples por tonalidad""" - results = [] - - for sample in self.samples: - if sample.get('key') == key: - if not category or sample['category'] == category: - results.append(sample) - - return results[:limit] - - def find_by_bpm(self, bpm: int, tolerance: int = 5, limit: int = 10) -> List[Dict[str, Any]]: - """Busca samples por BPM con tolerancia""" - results = [] - - for sample in self.samples: - sample_bpm = sample.get('bpm') - if sample_bpm and abs(sample_bpm - bpm) <= tolerance: - results.append(sample) - - return results[:limit] - - def get_random_sample(self, category: str = "") -> Optional[Dict[str, Any]]: - """Obtiene un sample aleatorio, opcionalmente filtrado por categoría""" - import random - - samples = self.samples - if category: - samples = [s for s in samples if s['category'] == category] - - return random.choice(samples) if samples else None - - def get_sample_pack(self, genre: str, key: str = "", bpm: int = 0) -> Dict[str, List[Dict]]: - """ - Obtiene un pack de samples completo para un género - - Args: - genre: Género musical - key: Tonalidad preferida - bpm: BPM preferido - - Returns: - Dict con samples organizados por categoría - """ - pack = { - 'kick': [], - 'snare': [], - 'hat': [], - 'clap': [], - 'perc': [], - 'bass': [], - 'synth': [], - 'fx': [], - } - - # Seleccionar un sample de cada categoría - for category in pack.keys(): - candidates = [s for s in self.samples if s['category'] == category] - - # Filtrar por key si se especificó - if key and candidates: - key_matches = [s for s in candidates if s.get('key') == key] - if key_matches: - candidates = key_matches - - # Filtrar por BPM si se especificó - if bpm and candidates: - bpm_matches = [s for s in candidates if s.get('bpm')] - if bpm_matches: - # Ordenar por cercanía al BPM objetivo - bpm_matches.sort(key=lambda s: abs(s['bpm'] - bpm)) - candidates = bpm_matches[:5] # Top 5 más cercanos - - # Seleccionar hasta 3 samples - import random - if candidates: - pack[category] = random.sample(candidates, min(3, len(candidates))) - - return pack - - def refresh(self): - """Reconstruye el índice desde cero""" - logger.info("Refrescando índice...") - self._build_index() - self._save_index() - - -# Función de utilidad para testing -if __name__ == "__main__": - import sys - - if len(sys.argv) < 2: - print("Uso: python sample_index.py ") - sys.exit(1) - - logging.basicConfig(level=logging.INFO) - - index = SampleIndex(sys.argv[1]) - - print(f"\nÍndice cargado: {len(index.samples)} samples") - print("\nDistribución por categoría:") - - categories = {} - for sample in index.samples: - cat = sample['category'] - categories[cat] = categories.get(cat, 0) + 1 - - for cat, count in sorted(categories.items(), key=lambda x: -x[1]): - print(f" {cat}: {count}") - - # Ejemplo de búsqueda - print("\nBúsqueda 'kick':") - for s in index.search("kick", limit=5): - print(f" - {s['name']} ({s.get('key', '?')}, {s.get('bpm', '?')} BPM)") diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_manager.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_manager.py deleted file mode 100644 index 8ff4148..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_manager.py +++ /dev/null @@ -1,1010 +0,0 @@ -""" -sample_manager.py - Gestión completa de librería de samples - -Proporciona: -- Indexación y escaneo de directorios de samples -- Clasificación automática por tipo, key, BPM -- Gestión de metadatos y tags -- Búsqueda avanzada con filtros múltiples -- Caché de índice para rendimiento -- Soporte para múltiples formatos (WAV, AIFF, MP3, OGG, FLAC) -""" - -import json -import hashlib -import logging -from pathlib import Path -from typing import Dict, List, Any, Optional, Tuple, Callable -from dataclasses import dataclass, field, asdict -from datetime import datetime -from collections import defaultdict -import threading - -# Importar analizador de audio -try: - from .audio_analyzer import AudioAnalyzer, SampleType, analyze_sample, quick_analyze - AUDIO_ANALYSIS_AVAILABLE = True -except ImportError: - try: - from audio_analyzer import AudioAnalyzer, SampleType, analyze_sample, quick_analyze - AUDIO_ANALYSIS_AVAILABLE = True - except ImportError: - AUDIO_ANALYSIS_AVAILABLE = False - AudioAnalyzer = None - SampleType = None - analyze_sample = None - quick_analyze = None - -logger = logging.getLogger("SampleManager") - - -@dataclass -class Sample: - """Representa un sample en la librería""" - id: str - name: str - path: str - category: str - subcategory: str - sample_type: str - key: Optional[str] = None - bpm: Optional[float] = None - duration: float = 0.0 - sample_rate: int = 44100 - channels: int = 2 - file_size: int = 0 - format: str = "wav" - - # Metadatos adicionales - genres: List[str] = field(default_factory=list) - tags: List[str] = field(default_factory=list) - mood: str = "" - energy: float = 0.5 # 0-1 - - # Información de análisis - analyzed: bool = False - analysis_version: int = 0 - spectral_centroid: float = 0.0 - rms_energy: float = 0.0 - is_harmonic: bool = False - is_percussive: bool = False - - # Metadatos del sistema - date_added: str = field(default_factory=lambda: datetime.now().isoformat()) - date_modified: str = field(default_factory=lambda: datetime.now().isoformat()) - play_count: int = 0 - rating: int = 0 # 0-5 - - def to_dict(self) -> Dict[str, Any]: - """Convierte el sample a diccionario""" - return asdict(self) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Sample': - """Crea un Sample desde un diccionario""" - # Filtrar solo los campos que existen en la clase - valid_fields = {f.name for f in cls.__dataclass_fields__.values()} - filtered_data = {k: v for k, v in data.items() if k in valid_fields} - return cls(**filtered_data) - - def get_display_name(self) -> str: - """Nombre formateado para mostrar""" - parts = [self.name] - if self.key: - parts.append(f"Key: {self.key}") - if self.bpm: - parts.append(f"{self.bpm:.1f} BPM") - return " | ".join(parts) - - -class SampleManager: - """ - Gestor principal de la librería de samples. - - Características: - - Indexación recursiva de directorios - - Clasificación automática por tipo - - Detección de key y BPM (si librosa está disponible) - - Búsqueda avanzada con múltiples filtros - - Sistema de favoritos y ratings - - Caché persistente en JSON - """ - - # Categorías principales y subcategorías - CATEGORIES = { - 'drums': { - 'kick': ['kick', 'bd', 'bass drum', 'kickdrum'], - 'snare': ['snare', 'snr', 'sd', 'rimshot'], - 'clap': ['clap', 'clp', 'handclap'], - 'hat_closed': ['closed hat', 'chh', 'closed'], - 'hat_open': ['open hat', 'ohh', 'open'], - 'hat': ['hat', 'hihat', 'hi-hat'], - 'perc': ['perc', 'percussion', 'conga', 'bongo', 'timbale'], - 'shaker': ['shaker', 'tambourine', 'tamb'], - 'tom': ['tom', 'tomtom'], - 'cymbal': ['crash', 'ride', 'cymbal', 'china'], - }, - 'bass': { - 'sub': ['sub', 'subbass', '808'], - 'bassline': ['bassline', 'bass', 'reese'], - 'acid': ['acid', 'tb303', '303'], - }, - 'synths': { - 'lead': ['lead', 'solo', 'main'], - 'pad': ['pad', 'atmosphere', 'dron', 'ambient'], - 'pluck': ['pluck', 'arp', 'arpeggio'], - 'chord': ['chord', 'stab', 'hit'], - 'fx': ['fx', 'effect', 'sweep', 'riser', 'downlifter'], - }, - 'vocals': { - 'vocal': ['vocal', 'vox', 'voice'], - 'speech': ['speech', 'talk', 'phrase'], - 'chant': ['chant', 'shout', 'yell'], - }, - 'loops': { - 'drum_loop': ['drum loop', 'beat loop', 'groove'], - 'perc_loop': ['perc loop', 'percussion loop'], - 'bass_loop': ['bass loop', 'bassline loop'], - 'synth_loop': ['synth loop', 'lead loop'], - 'full_loop': ['full loop', 'complete loop'], - }, - 'one_shots': { - 'hit': ['hit', 'impact', 'sting'], - 'noise': ['noise', 'texture', 'grain'], - } - } - - # Mapeo de extensiones de archivo - SUPPORTED_FORMATS = {'.wav', '.aif', '.aiff', '.mp3', '.ogg', '.flac', '.m4a'} - - # Géneros soportados con palabras clave - GENRE_KEYWORDS = { - 'house': ['house', 'deep', 'soulful', 'garage', 'classic'], - 'techno': ['techno', 'industrial', 'detroit', 'berlin', 'acid'], - 'tech-house': ['tech house', 'tech-house', 'groovy', 'bouncy'], - 'trance': ['trance', 'progressive', 'uplifting', 'psy'], - 'drum-and-bass': ['drum and bass', 'dnb', 'neuro', 'liquid', 'jungle'], - 'hip-hop': ['hip hop', 'hiphop', 'trap', 'boom bap', 'lofi'], - 'ambient': ['ambient', 'chillout', 'downtempo', 'meditation'], - 'edm': ['edm', 'electro', 'big room', 'festival'], - } - - def __init__(self, base_dir: str, cache_dir: Optional[str] = None): - """ - Inicializa el gestor de samples. - - Args: - base_dir: Directorio raíz de la librería de samples - cache_dir: Directorio para caché (default: base_dir/.sample_cache) - """ - self.base_dir = Path(base_dir) - self.cache_dir = Path(cache_dir) if cache_dir else self.base_dir / ".sample_cache" - self.cache_dir.mkdir(exist_ok=True) - - self.samples: Dict[str, Sample] = {} - self.index_file = self.cache_dir / "sample_library.json" - self.stats_file = self.cache_dir / "library_stats.json" - - # Analizador de audio - self.analyzer = AudioAnalyzer() if AUDIO_ANALYSIS_AVAILABLE else None - - # Locks para thread-safety - self._lock = threading.RLock() - self._index_dirty = False - - # Estadísticas - self.stats = { - 'total_samples': 0, - 'total_size': 0, - 'by_category': defaultdict(int), - 'by_key': defaultdict(int), - 'by_bpm_range': defaultdict(int), - 'last_scan': None, - } - - # Cargar índice existente - self._load_index() - - def _generate_id(self, file_path: str) -> str: - """Genera un ID único para un sample basado en su ruta""" - return hashlib.md5(file_path.encode()).hexdigest()[:16] - - def _get_file_hash(self, file_path: Path) -> str: - """Calcula hash del archivo para detectar cambios""" - stat = file_path.stat() - return hashlib.md5(f"{stat.st_size}_{stat.st_mtime}".encode()).hexdigest() - - def scan_directory(self, directory: Optional[str] = None, - recursive: bool = True, - analyze_audio: bool = False, - progress_callback: Optional[Callable[[int, int, str], None]] = None) -> Dict[str, Any]: - """ - Escanear un directorio en busca de samples. - - Args: - directory: Directorio a escanear (default: base_dir) - recursive: Escanear subdirectorios - analyze_audio: Analizar contenido de audio (más lento) - progress_callback: Función llamada con (procesados, total, archivo_actual) - - Returns: - Estadísticas del escaneo - """ - scan_dir = Path(directory) if directory else self.base_dir - - if not scan_dir.exists(): - raise FileNotFoundError(f"Directorio no encontrado: {scan_dir}") - - logger.info(f"Escaneando: {scan_dir}") - - # Encontrar todos los archivos de audio - if recursive: - audio_files = list(scan_dir.rglob('*')) - else: - audio_files = list(scan_dir.iterdir()) - - audio_files = [f for f in audio_files - if f.is_file() and f.suffix.lower() in self.SUPPORTED_FORMATS] - - total = len(audio_files) - processed = 0 - added = 0 - updated = 0 - errors = 0 - - logger.info(f"Encontrados {total} archivos de audio") - - with self._lock: - for file_path in audio_files: - processed += 1 - - if progress_callback: - progress_callback(processed, total, str(file_path.name)) - - try: - result = self._process_file(file_path, analyze_audio) - if result == 'added': - added += 1 - elif result == 'updated': - updated += 1 - - except Exception as e: - logger.error(f"Error procesando {file_path}: {e}") - errors += 1 - - self._index_dirty = True - self._update_stats() - self._save_index() - - self.stats['last_scan'] = datetime.now().isoformat() - - return { - 'processed': processed, - 'added': added, - 'updated': updated, - 'errors': errors, - 'total_samples': len(self.samples), - } - - def _process_file(self, file_path: Path, analyze_audio: bool) -> str: - """Procesa un archivo individual. Retorna 'added', 'updated', o 'unchanged'""" - file_id = self._generate_id(str(file_path)) - self._get_file_hash(file_path) - - # Verificar si ya existe y no ha cambiado - if file_id in self.samples: - existing = self.samples[file_id] - # Comparar hash implícito por fecha de modificación - current_stat = file_path.stat() - if existing.date_modified: - try: - mod_time = datetime.fromisoformat(existing.date_modified).timestamp() - if abs(current_stat.st_mtime - mod_time) < 1: - return 'unchanged' - except Exception: - pass - - # Extraer información del nombre - name = file_path.stem - category, subcategory = self._classify_by_name(name) - sample_type = self._detect_sample_type(name) - key = self._extract_key_from_name(name) - bpm = self._extract_bpm_from_name(name) - genres = self._detect_genres(name) - - # Análisis de audio si está disponible - audio_features = {} - if analyze_audio and self.analyzer: - try: - audio_features = analyze_sample(str(file_path)) - # Usar valores detectados si no están en el nombre - if not bpm and audio_features.get('bpm'): - bpm = audio_features['bpm'] - if not key and audio_features.get('key'): - key = audio_features['key'] - if audio_features.get('sample_type'): - sample_type = audio_features['sample_type'] - if audio_features.get('suggested_genres'): - genres = list(set(genres + audio_features['suggested_genres'])) - except Exception as e: - logger.warning(f"Error analizando audio {file_path}: {e}") - - # Crear o actualizar sample - is_new = file_id not in self.samples - - sample = Sample( - id=file_id, - name=name, - path=str(file_path), - category=category, - subcategory=subcategory, - sample_type=sample_type, - key=key, - bpm=bpm, - duration=audio_features.get('duration', 0.0), - sample_rate=audio_features.get('sample_rate', 44100), - file_size=file_path.stat().st_size, - format=file_path.suffix.lower().lstrip('.'), - genres=genres, - tags=self._extract_tags(name), - analyzed=analyze_audio, - spectral_centroid=audio_features.get('spectral_centroid', 0.0), - rms_energy=audio_features.get('rms_energy', 0.0), - is_harmonic=audio_features.get('is_harmonic', False), - is_percussive=audio_features.get('is_percussive', False), - date_modified=datetime.now().isoformat(), - ) - - self.samples[file_id] = sample - return 'added' if is_new else 'updated' - - def _classify_by_name(self, name: str) -> Tuple[str, str]: - """Clasifica un sample por su nombre en categoría y subcategoría""" - name_lower = name.lower() - - for category, subcategories in self.CATEGORIES.items(): - for subcategory, keywords in subcategories.items(): - for keyword in keywords: - if keyword in name_lower: - return category, subcategory - - # Fallback: intentar detectar loops - if 'loop' in name_lower: - return 'loops', 'unknown' - - return 'unknown', 'unknown' - - def _detect_sample_type(self, name: str) -> str: - """Detecta el tipo específico de sample""" - category, subcategory = self._classify_by_name(name) - - if category == 'drums': - return subcategory - elif category == 'bass': - return f"bass_{subcategory}" - elif category == 'synths': - return subcategory - elif category == 'vocals': - return subcategory - elif category == 'loops': - return subcategory - - return 'unknown' - - def _extract_key_from_name(self, name: str) -> Optional[str]: - """Extrae la tonalidad del nombre del archivo""" - import re - - # Patrones comunes - patterns = [ - r'[_\s\-]([A-G][#b]?(?:m|min|minor)?)[_\s\-]', - r'\bin\s+([A-G][#b]?(?:m|min|minor)?)\b', - r'Key[_\s]?([A-G][#b]?(?:m|min|minor)?)', - r'[_\s\-]([A-G][#b]?)\s*(?:maj|major)?[_\s\-]', - ] - - for pattern in patterns: - match = re.search(pattern, name, re.IGNORECASE) - if match: - key = match.group(1) - # Normalizar bemoles a sostenidos - key = key.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') - key = key.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') - - # Detectar modo - is_minor = 'm' in key.lower() or 'min' in key.lower() - key = key.replace('min', '').replace('minor', '').replace('major', '') - key = key.rstrip('mM') - - if is_minor: - key = key + 'm' - - return key - - return None - - def _extract_bpm_from_name(self, name: str) -> Optional[float]: - """Extrae el BPM del nombre del archivo""" - import re - - patterns = [ - r'[_\s\-](\d{2,3})\s*BPM', - r'[_\s\-](\d{2,3})[_\s\-]', - r'(\d{2,3})bpm', - ] - - for pattern in patterns: - match = re.search(pattern, name, re.IGNORECASE) - if match: - bpm = int(match.group(1)) - if 60 <= bpm <= 200: - return float(bpm) - - return None - - def _detect_genres(self, name: str) -> List[str]: - """Detecta géneros musicales del nombre""" - name_lower = name.lower() - genres = [] - - for genre, keywords in self.GENRE_KEYWORDS.items(): - for keyword in keywords: - if keyword in name_lower: - genres.append(genre) - break - - return genres - - def _extract_tags(self, name: str) -> List[str]: - """Extrae tags del nombre del archivo""" - import re - - tags = [] - name_lower = name.lower() - - # Palabras comunes como tags - common_tags = [ - 'dry', 'wet', 'processed', 'raw', 'analog', 'digital', - 'vintage', 'modern', 'punchy', 'deep', 'bright', 'dark', - 'tight', 'loose', 'fat', 'thin', 'crisp', 'warm', - 'one shot', 'loop', 'sample', 'hit' - ] - - for tag in common_tags: - if tag in name_lower: - tags.append(tag.replace(' ', '_')) - - # Extraer números como versiones - numbers = re.findall(r'\d+', name) - for num in numbers: - if len(num) <= 2: # Probablemente versión - tags.append(f"v{num}") - - return list(set(tags)) - - def search(self, - query: str = "", - category: str = "", - subcategory: str = "", - sample_type: str = "", - key: str = "", - bpm: Optional[float] = None, - bpm_tolerance: int = 5, - genres: List[str] = None, - tags: List[str] = None, - min_rating: int = 0, - favorites_only: bool = False, - limit: int = 50, - sort_by: str = "name") -> List[Sample]: - """ - Búsqueda avanzada de samples con múltiples filtros. - - Args: - query: Búsqueda por nombre - category: Categoría principal - subcategory: Subcategoría - sample_type: Tipo específico - key: Tonalidad musical - bpm: BPM objetivo - bpm_tolerance: Tolerancia de BPM (+/-) - genres: Lista de géneros - tags: Lista de tags - min_rating: Rating mínimo - favorites_only: Solo favoritos - limit: Límite de resultados - sort_by: Campo para ordenar - - Returns: - Lista de samples que coinciden - """ - with self._lock: - results = [] - query_lower = query.lower() - - for sample in self.samples.values(): - # Filtro por query (nombre) - if query and query_lower not in sample.name.lower(): - continue - - # Filtros de categoría - if category and sample.category != category.lower(): - continue - if subcategory and sample.subcategory != subcategory.lower(): - continue - if sample_type and sample.sample_type != sample_type.lower(): - continue - - # Filtro por key - if key: - sample_key = (sample.key or "").lower() - if sample_key != key.lower(): - # Intentar key compatible (mismo root) - if not sample_key.startswith(key.lower().rstrip('m')): - continue - - # Filtro por BPM - if bpm is not None and sample.bpm: - if abs(sample.bpm - bpm) > bpm_tolerance: - continue - - # Filtro por géneros - if genres: - sample_genres = [g.lower() for g in sample.genres] - if not any(g.lower() in sample_genres for g in genres): - continue - - # Filtro por tags - if tags: - sample_tags = [t.lower() for t in sample.tags] - if not any(t.lower() in sample_tags for t in tags): - continue - - # Filtro por rating - if min_rating > 0 and sample.rating < min_rating: - continue - - # Filtro de favoritos - if favorites_only and sample.rating < 4: - continue - - results.append(sample) - - # Ordenar resultados - if sort_by == "name": - results.sort(key=lambda s: s.name.lower()) - elif sort_by == "bpm": - results.sort(key=lambda s: s.bpm or 0) - elif sort_by == "rating": - results.sort(key=lambda s: s.rating, reverse=True) - elif sort_by == "date_added": - results.sort(key=lambda s: s.date_added, reverse=True) - - return results[:limit] - - def get_by_id(self, sample_id: str) -> Optional[Sample]: - """Obtiene un sample por su ID""" - with self._lock: - return self.samples.get(sample_id) - - def get_by_path(self, file_path: str) -> Optional[Sample]: - """Obtiene un sample por su ruta""" - sample_id = self._generate_id(file_path) - return self.get_by_id(sample_id) - - def get_random(self, category: str = "", limit: int = 1) -> List[Sample]: - """Obtiene samples aleatorios""" - import random - - with self._lock: - samples = list(self.samples.values()) - - if category: - samples = [s for s in samples if s.category == category] - - if not samples: - return [] - - return random.sample(samples, min(limit, len(samples))) - - def get_pack_for_genre(self, genre: str, key: str = "", - bpm: Optional[float] = None) -> Dict[str, List[Sample]]: - """ - Obtiene un pack completo de samples para un género específico. - - Returns: - Dict con samples organizados por tipo - """ - pack = { - 'kicks': [], - 'snares': [], - 'claps': [], - 'hats': [], - 'percussion': [], - 'bass': [], - 'synths': [], - 'fx': [], - } - - # Buscar samples por tipo - type_mapping = { - 'kicks': ['kick'], - 'snares': ['snare'], - 'claps': ['clap'], - 'hats': ['hat', 'hat_closed', 'hat_open'], - 'percussion': ['perc', 'shaker', 'tom', 'cymbal'], - 'bass': ['bass', 'sub', 'bassline', 'acid'], - 'synths': ['lead', 'pad', 'pluck', 'chord'], - 'fx': ['fx', 'hit', 'noise'], - } - - for pack_category, sample_types in type_mapping.items(): - for sample_type in sample_types: - samples = self.search( - sample_type=sample_type, - key=key, - bpm=bpm, - genres=[genre] if genre else None, - limit=5 - ) - - if samples: - pack[pack_category].extend(samples) - - return pack - - def update_sample(self, sample_id: str, **kwargs) -> bool: - """ - Actualiza metadatos de un sample. - - Args: - sample_id: ID del sample - **kwargs: Campos a actualizar - """ - with self._lock: - if sample_id not in self.samples: - return False - - sample = self.samples[sample_id] - - # Campos permitidos para actualización - allowed_fields = { - 'rating', 'tags', 'genres', 'mood', 'energy', - 'key', 'bpm', 'play_count' - } - - for field, value in kwargs.items(): - if field in allowed_fields and hasattr(sample, field): - setattr(sample, field, value) - - sample.date_modified = datetime.now().isoformat() - self._index_dirty = True - - return True - - def rate_sample(self, sample_id: str, rating: int) -> bool: - """Califica un sample (1-5 estrellas)""" - if 0 <= rating <= 5: - return self.update_sample(sample_id, rating=rating) - return False - - def increment_play_count(self, sample_id: str) -> bool: - """Incrementa el contador de reproducciones""" - sample = self.get_by_id(sample_id) - if sample: - return self.update_sample(sample_id, play_count=sample.play_count + 1) - return False - - def delete_sample(self, sample_id: str, delete_file: bool = False) -> bool: - """ - Elimina un sample del índice. - - Args: - sample_id: ID del sample - delete_file: Si True, también elimina el archivo físico - """ - with self._lock: - if sample_id not in self.samples: - return False - - sample = self.samples[sample_id] - - if delete_file: - try: - Path(sample.path).unlink() - except Exception as e: - logger.error(f"Error eliminando archivo: {e}") - return False - - del self.samples[sample_id] - self._index_dirty = True - self._update_stats() - - return True - - def refresh(self, analyze_audio: bool = False) -> Dict[str, Any]: - """Refresca el índice completo""" - logger.info("Refrescando índice de samples...") - - # Guardar IDs actuales para detectar eliminados - current_paths = {s.path for s in self.samples.values()} - - # Re-escanear - stats = self.scan_directory(analyze_audio=analyze_audio) - - # Detectar archivos eliminados - new_paths = {s.path for s in self.samples.values()} - removed = current_paths - new_paths - - for path in removed: - sample_id = self._generate_id(path) - if sample_id in self.samples: - del self.samples[sample_id] - stats['removed'] = stats.get('removed', 0) + 1 - - self._save_index() - return stats - - def get_stats(self) -> Dict[str, Any]: - """Obtiene estadísticas de la librería""" - with self._lock: - return { - 'total_samples': len(self.samples), - 'total_size': sum(s.file_size for s in self.samples.values()), - 'by_category': dict(self.stats['by_category']), - 'by_key': dict(self.stats['by_key']), - 'by_bpm_range': dict(self.stats['by_bpm_range']), - 'last_scan': self.stats['last_scan'], - } - - def export_library(self, output_path: str, format: str = "json") -> str: - """ - Exporta la librería a un archivo. - - Args: - output_path: Ruta del archivo de salida - format: 'json' o 'csv' - - Returns: - Ruta del archivo exportado - """ - output = Path(output_path) - - with self._lock: - if format == "json": - data = { - 'export_date': datetime.now().isoformat(), - 'stats': self.get_stats(), - 'samples': [s.to_dict() for s in self.samples.values()] - } - with open(output, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - elif format == "csv": - import csv - with open(output, 'w', newline='', encoding='utf-8') as f: - if self.samples: - writer = csv.DictWriter(f, fieldnames=self.samples[list(self.samples.keys())[0]].to_dict().keys()) - writer.writeheader() - for sample in self.samples.values(): - writer.writerow(sample.to_dict()) - - return str(output) - - def import_library(self, input_path: str, merge: bool = True) -> Dict[str, int]: - """ - Importa una librería desde un archivo JSON. - - Args: - input_path: Ruta del archivo a importar - merge: Si True, mezcla con la librería existente - - Returns: - Estadísticas de la importación - """ - with open(input_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - imported_samples = data.get('samples', []) - - with self._lock: - if not merge: - self.samples.clear() - - added = 0 - updated = 0 - - for sample_data in imported_samples: - try: - sample = Sample.from_dict(sample_data) - if sample.id in self.samples: - updated += 1 - else: - added += 1 - self.samples[sample.id] = sample - except Exception as e: - logger.error(f"Error importando sample: {e}") - - self._index_dirty = True - self._update_stats() - self._save_index() - - return {'added': added, 'updated': updated} - - def _update_stats(self): - """Actualiza las estadísticas de la librería""" - self.stats['total_samples'] = len(self.samples) - self.stats['total_size'] = sum(s.file_size for s in self.samples.values()) - - # Resetear contadores - self.stats['by_category'] = defaultdict(int) - self.stats['by_key'] = defaultdict(int) - self.stats['by_bpm_range'] = defaultdict(int) - - for sample in self.samples.values(): - self.stats['by_category'][sample.category] += 1 - - if sample.key: - self.stats['by_key'][sample.key] += 1 - - if sample.bpm: - if sample.bpm < 100: - self.stats['by_bpm_range']['slow (<100)'] += 1 - elif sample.bpm < 128: - self.stats['by_bpm_range']['mid (100-128)'] += 1 - elif sample.bpm < 140: - self.stats['by_bpm_range']['fast (128-140)'] += 1 - else: - self.stats['by_bpm_range']['very fast (>140)'] += 1 - - def _load_index(self): - """Carga el índice desde disco""" - if not self.index_file.exists(): - logger.info("No existe índice previo, iniciando librería vacía") - return - - try: - with open(self.index_file, 'r', encoding='utf-8') as f: - data = json.load(f) - - for sample_data in data.get('samples', []): - try: - sample = Sample.from_dict(sample_data) - self.samples[sample.id] = sample - except Exception as e: - logger.warning(f"Error cargando sample: {e}") - - self.stats = data.get('stats', self.stats) - logger.info(f"Índice cargado: {len(self.samples)} samples") - - except Exception as e: - logger.error(f"Error cargando índice: {e}") - - def _save_index(self): - """Guarda el índice a disco""" - if not self._index_dirty: - return - - try: - data = { - 'version': 1, - 'saved_at': datetime.now().isoformat(), - 'stats': self.get_stats(), - 'samples': [s.to_dict() for s in self.samples.values()] - } - - # Guardar a archivo temporal primero - temp_file = self.index_file.with_suffix('.tmp') - with open(temp_file, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - # Renombrar atómicamente - temp_file.replace(self.index_file) - - self._index_dirty = False - logger.info(f"Índice guardado: {len(self.samples)} samples") - - except Exception as e: - logger.error(f"Error guardando índice: {e}") - - def save(self): - """Fuerza el guardado del índice""" - self._index_dirty = True - self._save_index() - - -# Instancia global -_manager: Optional[SampleManager] = None - - -def get_manager(base_dir: Optional[str] = None) -> SampleManager: - """Obtiene la instancia global del gestor""" - global _manager - if _manager is None: - if base_dir is None: - PACKAGE_DIR = Path(__file__).resolve().parent.parent - base_dir = str(Path(r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\librerias\all_tracks")) - _manager = SampleManager(base_dir) - return _manager - - -# Funciones de conveniencia -def scan_samples(directory: str, analyze_audio: bool = False) -> Dict[str, Any]: - """Escanear directorio de samples""" - manager = get_manager(directory) - return manager.scan_directory(analyze_audio=analyze_audio) - - -def find_samples(query: str = "", **kwargs) -> List[Dict[str, Any]]: - """Buscar samples""" - manager = get_manager() - samples = manager.search(query=query, **kwargs) - return [s.to_dict() for s in samples] - - -def get_sample_pack(genre: str, key: str = "", bpm: Optional[float] = None) -> Dict[str, List[Dict]]: - """Obtener pack de samples para un género""" - manager = get_manager() - pack = manager.get_pack_for_genre(genre, key, bpm) - return {k: [s.to_dict() for s in v] for k, v in pack.items()} - - -# Testing -if __name__ == "__main__": - import sys - - logging.basicConfig(level=logging.INFO) - - if len(sys.argv) < 2: - print("Uso: python sample_manager.py [comando]") - print("\nComandos:") - print(" scan - Escanear directorio") - print(" stats - Mostrar estadísticas") - print(" search - Buscar samples") - sys.exit(1) - - directory = sys.argv[1] - command = sys.argv[2] if len(sys.argv) > 2 else "scan" - - manager = SampleManager(directory) - - if command == "scan": - print(f"\nEscaneando: {directory}") - print("=" * 50) - - def progress(current, total, filename): - pct = (current / total) * 100 - print(f"\r[{pct:5.1f}%] {filename[:50]:<50}", end="", flush=True) - - stats = manager.scan_directory(progress_callback=progress) - print("\n") - print(f"Procesados: {stats['processed']}") - print(f"Agregados: {stats['added']}") - print(f"Actualizados: {stats['updated']}") - print(f"Errores: {stats['errors']}") - print(f"Total en librería: {stats['total_samples']}") - - elif command == "stats": - stats = manager.get_stats() - print("\nEstadísticas de la librería:") - print("=" * 50) - print(f"Total samples: {stats['total_samples']}") - print(f"Tamaño total: {stats['total_size'] / (1024**2):.1f} MB") - print(f"Último escaneo: {stats['last_scan']}") - print("\nPor categoría:") - for cat, count in sorted(stats['by_category'].items()): - print(f" {cat}: {count}") - print("\nPor key:") - for key, count in sorted(stats['by_key'].items()): - print(f" {key}: {count}") - - elif command == "search": - query = sys.argv[3] if len(sys.argv) > 3 else "" - print(f"\nBuscando: '{query}'") - print("=" * 50) - - results = manager.search(query=query, limit=20) - for s in results: - print(f"\n{s.name}") - print(f" Categoría: {s.category}/{s.subcategory}") - print(f" Key: {s.key or 'N/A'} | BPM: {s.bpm or 'N/A'}") - print(f" Path: {s.path}") diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_system_demo.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_system_demo.py deleted file mode 100644 index 3e70974..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/sample_system_demo.py +++ /dev/null @@ -1,244 +0,0 @@ -""" -Demo del Sistema de Gestión de Samples para AbletonMCP-AI - -Este script demuestra las capacidades del sistema completo de samples. -""" - -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent)) - -from sample_manager import get_manager -from sample_selector import get_selector -from audio_analyzer import analyze_sample, AudioAnalyzer - - -def demo_analyzer(): - """Demostración del analizador de audio""" - print("=" * 60) - print("DEMO: Audio Analyzer") - print("=" * 60) - - AudioAnalyzer(backend='basic') - - # Analizar un archivo de ejemplo - test_file = r"C:\Users\ren\embeddings\all_tracks\BBH - Primer Impacto - Kick 1.wav" - - print(f"\nAnalizando: {Path(test_file).name}") - print("-" * 40) - - try: - result = analyze_sample(test_file) - - print(f"Tipo detectado: {result['sample_type']}") - print(f"BPM: {result.get('bpm') or 'No detectado'}") - print(f"Key: {result.get('key') or 'No detectado'}") - print(f"Duración: {result['duration']:.3f}s") - print(f"Es percusivo: {result['is_percussive']}") - print(f"Géneros sugeridos: {', '.join(result['suggested_genres'])}") - - except Exception as e: - print(f"Error: {e}") - - print() - - -def demo_manager(): - """Demostración del gestor de samples""" - print("=" * 60) - print("DEMO: Sample Manager") - print("=" * 60) - - manager = get_manager(r"C:\Users\ren\embeddings\all_tracks") - - # Escanear librería - print("\nEscaneando librería...") - stats = manager.scan_directory() - print(f" Samples procesados: {stats['processed']}") - print(f" Nuevos: {stats['added']}") - print(f" Total en librería: {stats['total_samples']}") - - # Estadísticas - print("\nEstadísticas:") - stats = manager.get_stats() - print(f" Total: {stats['total_samples']} samples") - print(f" Tamaño: {stats['total_size'] / (1024**2):.1f} MB") - - if stats['by_category']: - print("\n Por categoría:") - for cat, count in sorted(stats['by_category'].items(), key=lambda x: -x[1]): - print(f" {cat}: {count}") - - if stats['by_key']: - print("\n Por key:") - for key, count in sorted(stats['by_key'].items(), key=lambda x: -x[1]): - print(f" {key}: {count}") - - # Búsquedas - print("\nBúsquedas:") - print("-" * 40) - - # Buscar kicks - kicks = manager.search(sample_type="kick", limit=3) - print(f"\nKicks encontrados: {len(kicks)}") - for s in kicks: - print(f" - {s.name}") - - # Buscar por key - g_sharp = manager.search(key="G#m", limit=3) - print(f"\nSamples en G#m: {len(g_sharp)}") - for s in g_sharp: - print(f" - {s.name} ({s.sample_type})") - - # Buscar por BPM - bpm_128 = manager.search(bpm=128, bpm_tolerance=5, limit=3) - print(f"\nSamples ~128 BPM: {len(bpm_128)}") - for s in bpm_128: - key_info = f" [{s.key}]" if s.key else "" - print(f" - {s.name}{key_info}") - - print() - - -def demo_selector(): - """Demostración del selector inteligente""" - print("=" * 60) - print("DEMO: Sample Selector") - print("=" * 60) - - selector = get_selector() - - # Seleccionar para diferentes géneros - genres = ['techno', 'house', 'tech-house'] - - for genre in genres: - print(f"\n{genre.upper()}:") - print("-" * 40) - - group = selector.select_for_genre(genre, key='Am', bpm=128) - - print(f" Key: {group.key} | BPM: {group.bpm}") - - # Drum kit - kit = group.drums - print("\n Drum Kit:") - if kit.kick: - print(f" Kick: {kit.kick.name}") - if kit.snare: - print(f" Snare: {kit.snare.name}") - if kit.clap: - print(f" Clap: {kit.clap.name}") - if kit.hat_closed: - print(f" Hat: {kit.hat_closed.name}") - - # Mapeo MIDI - mapping = selector.get_midi_mapping_for_kit(kit) - print("\n Mapeo MIDI:") - for note, info in sorted(mapping['notes'].items())[:4]: - if info['sample']: - print(f" Note {note}: {info['sample'][:40]}...") - - # Bass - if group.bass: - print(f"\n Bass ({len(group.bass)}):") - for s in group.bass[:2]: - key_info = f" [{s.key}]" if s.key else "" - print(f" - {s.name}{key_info}") - - # Cambio de key - print("\n" + "-" * 40) - print("Cambios de Key Sugeridos (desde Am):") - changes = ['fifth_up', 'fifth_down', 'relative', 'parallel'] - for change in changes: - new_key = selector.suggest_key_change('Am', change) - print(f" {change}: {new_key}") - - print() - - -def demo_compatibility(): - """Demostración de búsqueda de samples compatibles""" - print("=" * 60) - print("DEMO: Compatibilidad de Samples") - print("=" * 60) - - manager = get_manager() - selector = get_selector() - - # Encontrar un sample con key para usar de referencia - samples_with_key = manager.search(key="G#m", limit=1) - - if samples_with_key: - reference = samples_with_key[0] - print(f"\nSample de referencia: {reference.name}") - print(f" Key: {reference.key} | BPM: {reference.bpm}") - - # Buscar compatibles - compatible = selector.find_compatible_samples(reference, max_results=5) - - print("\nSamples compatibles:") - print("-" * 40) - - for sample, score in compatible: - bar_len = int(score * 20) - bar = "█" * bar_len + "░" * (20 - bar_len) - print(f" [{bar}] {score:.1%} - {sample.name}") - - print() - - -def demo_pack_generation(): - """Demostración de generación de packs""" - print("=" * 60) - print("DEMO: Generación de Sample Packs") - print("=" * 60) - - manager = get_manager() - - genres = ['techno', 'house', 'deep-house'] - - for genre in genres: - print(f"\n{genre.upper()} Pack:") - print("-" * 40) - - pack = manager.get_pack_for_genre(genre, key='Am', bpm=128) - - total = 0 - for category, samples in pack.items(): - if samples: - count = len(samples) - total += count - print(f" {category}: {count}") - - print(f" Total: {total} samples") - - print() - - -def main(): - """Ejecutar todas las demos""" - print("\n") - print("=" * 60) - print(" AbletonMCP-AI Sample System Demo ".center(60)) - print("=" * 60) - print() - - try: - demo_analyzer() - demo_manager() - demo_selector() - demo_compatibility() - demo_pack_generation() - - print("=" * 60) - print("Todas las demos completadas exitosamente!") - print("=" * 60) - - except Exception as e: - print(f"\nError en demo: {e}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - main() diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/segment_rag_builder.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/segment_rag_builder.py deleted file mode 100644 index 3f97f68..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/segment_rag_builder.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -segment_rag_builder.py - Build or refresh the persistent segment-audio index. -""" - -from __future__ import annotations - -import argparse -import json -import logging -from pathlib import Path - -from reference_listener import ReferenceAudioListener, export_segment_rag_manifest, generate_segment_rag_summary, _get_segment_rag_status, _backfill_segment_cache_metadata - - -logger = logging.getLogger(__name__) - - -def _default_library_dir() -> Path: - return Path(__file__).resolve().parents[2] / "librerias" / "all_tracks" - - -def main() -> int: - parser = argparse.ArgumentParser(description="Build the persistent segment-audio retrieval cache.") - parser.add_argument("--library-dir", default=str(_default_library_dir()), help="Audio library directory") - parser.add_argument("--roles", nargs="*", default=None, help="Subset of roles to index") - parser.add_argument("--max-files", type=int, default=None, help="Optional limit for targeted files") - parser.add_argument("--duration-limit", type=float, default=24.0, help="Max seconds per file during indexing") - parser.add_argument("--force", action="store_true", help="Rebuild even if persistent segment cache already exists") - parser.add_argument("--json", action="store_true", help="Emit full JSON report") - parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output") - parser.add_argument("--offset", type=int, default=0, help="Skip first N files before starting (for chunked indexing)") - parser.add_argument("--batch-size", type=int, default=None, help="Process exactly N files then stop (for chunked indexing)") - parser.add_argument("--output-manifest", type=str, default=None, help="Path to save full manifest JSON") - parser.add_argument("--output-summary", type=str, default=None, help="Path to save summary report") - parser.add_argument("--resume", action="store_true", help="Resume from previous run state") - parser.add_argument("--export-manifest", type=str, default=None, - help="Export candidate manifest to FILE (format: .json or .md)") - parser.add_argument("--export-format", type=str, default="json", - choices=['json', 'markdown'], help="Manifest export format") - parser.add_argument("--status", action="store_true", help="Show current index status without building") - parser.add_argument("--backfill-metadata", action="store_true", help="Backfill metadata into existing cache files from indexing state") - parser.add_argument("--force-backfill", action="store_true", help="Force backfill even for files that already have metadata") - args = parser.parse_args() - - # Configure logging based on verbose flag - if args.verbose: - logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s') - else: - logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') - - # Handle --status flag for early exit - if args.status: - status = _get_segment_rag_status(Path(args.library_dir)) - - if args.json: - print(json.dumps(status, indent=2, default=str)) - else: - print("=" * 60) - print("SEGMENT RAG INDEX STATUS") - print("=" * 60) - print(f"Cache Directory: {status['cache_dir']}") - print(f"Cache Files: {status['cache_files']}") - print(f"Total Indexed Segments: {status['total_segments']}") - print(f"Status: {status.get('status', 'unknown')}") - - if status.get('role_coverage'): - print("\nRole Coverage:") - for role, count in sorted(status['role_coverage'].items()): - print(f" {role}: {count} segments") - - if status.get('newest_entries'): - print(f"\nNewest Entries: {len(status['newest_entries'])} files") - for entry in status['newest_entries'][:5]: - print(f" - {entry['file_name']} ({entry['segments']} segments)") - - if status.get('oldest_entries'): - print(f"\nOldest Entries: {len(status['oldest_entries'])} files") - for entry in status['oldest_entries'][:5]: - print(f" - {entry['file_name']} ({entry['segments']} segments)") - - return 0 - - # Handle --backfill-metadata flag for early exit - if args.backfill_metadata: - result = _backfill_segment_cache_metadata(Path(args.library_dir), force=args.force_backfill) - - if args.json: - print(json.dumps(result, indent=2, default=str)) - else: - print("=" * 60) - print("SEGMENT CACHE METADATA BACKFILL") - print("=" * 60) - print(f"Cache Directory: {result['cache_dir']}") - print(f"Cache Files: {result['cache_files']}") - print(f"Backfilled: {result['backfilled']}") - print(f"Skipped: {result['skipped']}") - print(f"Errors: {result['errors']}") - print(f"Status: {result.get('status', 'unknown')}") - - return 0 - - listener = ReferenceAudioListener(args.library_dir) - report = listener.build_segment_rag_index( - roles=args.roles, - max_files=args.max_files, - duration_limit=args.duration_limit, - force=args.force, - offset=args.offset, - batch_size=args.batch_size, - resume=args.resume, - ) - - # Generate enhanced summary - summary = generate_segment_rag_summary(report, Path(args.library_dir)) - - if args.json: - print(json.dumps(summary, indent=2, default=str)) - else: - # Enhanced text output - print("=" * 60) - print("SEGMENT RAG INDEX COMPLETE") - print("=" * 60) - print(f"Device: {summary['device']}") - print(f"Cache: {summary['segment_index_dir']}") - print() - print(f"Files: {summary['files_targeted']} targeted") - print(f" Built: {summary['built']}") - print(f" Reused: {summary['reused']}") - print(f" Skipped: {summary['skipped']}") - print(f" Errors: {summary['errors']}") - print() - print(f"Total Segments: {summary['total_segments']}") - - if 'summary_stats' in summary: - stats = summary['summary_stats'] - print(f" Avg per file: {stats['avg_segments_per_file']:.1f}") - print(f" Range: {stats['min_segments']} - {stats['max_segments']}") - - if 'role_coverage' in summary: - print("\nRole Coverage:") - for role in sorted(summary['role_coverage'].keys()): - print(f" {role}: {summary['role_coverage'][role]} segments") - - if 'cache_info' in summary: - info = summary['cache_info'] - print(f"\nCache Size: {info['cache_size_mb']} MB") - - if args.offset > 0: - print(f"\nOffset: {args.offset}") - if args.batch_size is not None: - print(f"Batch Size: {args.batch_size}") - print(f"Files Remaining: {summary.get('files_remaining', 'unknown')}") - - # Save manifest if requested - if args.output_manifest: - manifest_path = Path(args.output_manifest) - manifest_path.parent.mkdir(parents=True, exist_ok=True) - with open(manifest_path, 'w') as f: - json.dump({ - "report": report, - "full_manifest": report.get("manifest", []), - }, f, indent=2) - if not args.json: - print(f"\nManifest saved to: {manifest_path}") - - # Save summary if requested - if args.output_summary: - summary_path = Path(args.output_summary) - summary_path.parent.mkdir(parents=True, exist_ok=True) - with open(summary_path, 'w') as f: - json.dump(summary, f, indent=2, default=str) - if not args.json: - print(f"Summary saved to: {summary_path}") - - # Export manifest in requested format - if args.export_manifest: - manifest_path = Path(args.export_manifest) - export_format = args.export_format - - # Determine format from extension if not specified - if not args.export_format or args.export_format == "json": - if manifest_path.suffix == '.md': - export_format = 'markdown' - else: - export_format = 'json' - - export_segment_rag_manifest( - report.get('manifest', []), - manifest_path, - format=export_format - ) - print(f"Manifest exported to: {manifest_path}") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/server_v2.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/server_v2.py deleted file mode 100644 index 6c152db..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/server_v2.py +++ /dev/null @@ -1,1366 +0,0 @@ -""" -AbletonMCP AI Server v2 - Servidor MCP robusto para generación musical -Integra FastMCP con Ableton Live 12 via socket TCP y Max for Live via UDP - -Para ejecutar: - python -m AbletonMCP_AI.MCP_Server.server_v2 - -O con uv: - uv run python -m AbletonMCP_AI.MCP_Server.server_v2 -""" - -from mcp.server.fastmcp import FastMCP, Context -import socket -import json -import logging -import sys -from dataclasses import dataclass -from contextlib import asynccontextmanager -from typing import AsyncIterator, Dict, Any, List, Optional -from pathlib import Path -from datetime import datetime - -# Añadir el path para imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -try: -# from song_generator import SongGenerator, StyleConfig - from sample_index import SampleIndex -except ImportError as e: - print(f"Error importando módulos locales: {e}") - SongGenerator = None - SampleIndex = None - -# Configuración de logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(), - logging.FileHandler(Path(__file__).parent / 'server_v2.log', mode='a') - ] -) -logger = logging.getLogger("AbletonMCP-AI-v2") - -# ============================================================================ -# CONSTANTES Y CONFIGURACIÓN -# ============================================================================ - -DEFAULT_ABLETON_PORT = 9877 -DEFAULT_MAX_PORT = 9879 -MAX_HOST = "127.0.0.1" -ABLETON_HOST = "localhost" -SAMPLES_DIR = r"C:\Users\ren\embeddings\all_tracks" - -# Colores por tipo de track -TRACK_COLORS = { - 'kick': 10, # Rojo - 'snare': 20, # Verde - 'hat': 5, # Amarillo - 'clap': 45, # Naranja - 'bass': 30, # Azul - 'synth': 50, # Rosa/Magenta - 'chords': 60, # Púrpura - 'fx': 25, # Verde claro - 'vocal': 15, # Naranja oscuro -} - -# Instrucciones para el productor (contexto de IA) -PRODUCER_INSTRUCTIONS = """ -Eres AbletonMCP-AI v2, un productor musical experto integrado con Ableton Live 12 y Max for Live. -Tu objetivo es crear música electrónica profesional mediante prompts en lenguaje natural. - -CAPACIDADES PRINCIPALES: -1. Generar tracks completos con estructura profesional (Intro, Build, Drop, Break, Outro) -2. Crear patrones MIDI para diferentes géneros (Techno, House, Trance, Tech-House, etc.) -3. Seleccionar y cargar samples apropiados desde la librería local -4. Enviar rutas de samples a Max for Live para carga dinámica -5. Configurar BPM, tonalidad y estructura musical -6. Controlar transporte (play, stop, tempo) -7. Crear clips y escenas en Ableton - -HERRAMIENTAS DISPONIBLES: -- generate_song(genre, style, bpm): Genera una canción completa -- load_sample_kit(genre): Carga un kit de samples para un género -- create_pattern(instrument, pattern_type): Crea patrones MIDI -- control_transport(action): Controla reproducción -- get_session_info(): Obtiene información de la sesión - -ESTILOS SOPORTADOS: -- Techno: Industrial, Peak Time, Dub, Minimal, Acid -- House: Deep, Tech-House, Progressive, Afro, Classic 90s -- Trance: Psy, Progressive, Uplifting -- Drum & Bass: Liquid, Neuro, Jump-up, Jungle - -FLUJO DE TRABAJO: -1. Analizar el prompt del usuario para extraer género, BPM, tonalidad, mood -2. Detectar samples disponibles en la librería -3. Generar patrones MIDI característicos del género -4. Enviar comandos a Ableton via socket TCP -5. Enviar rutas de samples a Max via UDP -6. Proporcionar feedback sobre lo creado - -REGLAS: -- Siempre verifica la conexión con Ableton antes de ejecutar comandos -- Usa valores por defecto razonables si el usuario no especifica -- Organiza los tracks con colores consistentes -- Maneja errores gracefully y proporciona mensajes útiles -- Loggea todas las operaciones para debugging -""".strip() - - -# ============================================================================ -# CLASES DE CONEXIÓN -# ============================================================================ - -@dataclass -class AbletonConnection: - """Gestiona la conexión TCP con Ableton Live""" - host: str = ABLETON_HOST - port: int = DEFAULT_ABLETON_PORT - sock: Optional[socket.socket] = None - connected: bool = False - last_error: Optional[str] = None - - def connect(self, timeout: float = 5.0) -> bool: - """Conecta al Remote Script de Ableton""" - if self.connected and self.sock: - return True - - try: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.settimeout(timeout) - self.sock.connect((self.host, self.port)) - self.sock.settimeout(None) # Non-blocking después de conectar - self.connected = True - self.last_error = None - logger.info(f"Conectado a Ableton en {self.host}:{self.port}") - return True - except socket.timeout: - self.last_error = f"Timeout conectando a {self.host}:{self.port}" - logger.error(self.last_error) - self.sock = None - self.connected = False - return False - except Exception as e: - self.last_error = f"Error conectando a Ableton: {e}" - logger.error(self.last_error) - self.sock = None - self.connected = False - return False - - def disconnect(self): - """Desconecta de Ableton""" - if self.sock: - try: - self.sock.close() - except Exception as e: - logger.error(f"Error desconectando: {e}") - finally: - self.sock = None - self.connected = False - logger.info("Desconectado de Ableton") - - def send_command(self, command_type: str, params: Dict[str, Any] = None, - timeout: float = 15.0) -> Dict[str, Any]: - """Envía un comando a Ableton y retorna la respuesta""" - if not self.connected and not self.connect(): - return {"status": "error", "message": "No conectado a Ableton"} - - command = { - "type": command_type, - "params": params or {} - } - - try: - logger.debug(f"Enviando comando: {command_type}") - self.sock.sendall(json.dumps(command).encode('utf-8')) - - # Recibir respuesta - self.sock.settimeout(timeout) - chunks = [] - - while True: - try: - chunk = self.sock.recv(8192) - if not chunk: - break - chunks.append(chunk) - - # Intentar parsear JSON completo - try: - data = b''.join(chunks) - response = json.loads(data.decode('utf-8')) - return response - except json.JSONDecodeError: - continue - - except socket.timeout: - logger.warning("Timeout esperando respuesta") - break - - # Respuesta incompleta - if chunks: - data = b''.join(chunks) - try: - return json.loads(data.decode('utf-8')) - except Exception: - return {"status": "error", "message": "Respuesta JSON incompleta"} - else: - return {"status": "error", "message": "No se recibió respuesta"} - - except socket.error as e: - self.connected = False - self.last_error = f"Error de socket: {e}" - logger.error(self.last_error) - return {"status": "error", "message": str(e)} - except Exception as e: - self.connected = False - self.last_error = f"Error en comunicación: {e}" - logger.error(self.last_error) - return {"status": "error", "message": str(e)} - - -@dataclass -class MaxConnection: - """Gestiona la conexión UDP con Max for Live""" - host: str = MAX_HOST - port: int = DEFAULT_MAX_PORT - sock: Optional[socket.socket] = None - - def __post_init__(self): - self._init_socket() - - def _init_socket(self): - """Inicializa el socket UDP""" - try: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - logger.info(f"Socket UDP inicializado para Max en {self.host}:{self.port}") - except Exception as e: - logger.error(f"Error inicializando socket UDP: {e}") - self.sock = None - - def send_message(self, message: Dict[str, Any]) -> bool: - """Envía un mensaje JSON a Max for Live via UDP""" - if not self.sock: - self._init_socket() - if not self.sock: - return False - - try: - data = json.dumps(message).encode('utf-8') - self.sock.sendto(data, (self.host, self.port)) - logger.debug(f"Mensaje enviado a Max: {message.get('type', 'unknown')}") - return True - except Exception as e: - logger.error(f"Error enviando mensaje a Max: {e}") - return False - - def send_sample_path(self, track_index: int, sample_path: str, - slot: int = 0) -> bool: - """Envía una ruta de sample a Max para cargar""" - message = { - "type": "load_sample", - "track_index": track_index, - "sample_path": sample_path, - "slot": slot, - "timestamp": datetime.now().isoformat() - } - return self.send_message(message) - - def send_sample_kit(self, kit: Dict[str, List[Dict]]) -> bool: - """Envía un kit completo de samples a Max""" - message = { - "type": "load_sample_kit", - "kit": kit, - "timestamp": datetime.now().isoformat() - } - return self.send_message(message) - - def send_command(self, command: str, params: Dict[str, Any] = None) -> bool: - """Envía un comando genérico a Max""" - message = { - "type": "command", - "command": command, - "params": params or {}, - "timestamp": datetime.now().isoformat() - } - return self.send_message(message) - - -# ============================================================================ -# GESTORES GLOBALES -# ============================================================================ - -_ableton_connection: Optional[AbletonConnection] = None -_max_connection: Optional[MaxConnection] = None -_sample_index: Optional['SampleIndex'] = None -_song_generator: Optional['SongGenerator'] = None - - -def get_ableton_connection() -> AbletonConnection: - """Obtiene o crea la conexión con Ableton""" - global _ableton_connection - if _ableton_connection is None: - _ableton_connection = AbletonConnection() - return _ableton_connection - - -def get_max_connection() -> MaxConnection: - """Obtiene o crea la conexión con Max""" - global _max_connection - if _max_connection is None: - _max_connection = MaxConnection() - return _max_connection - - -def get_sample_index() -> Optional['SampleIndex']: - """Obtiene o crea el índice de samples""" - global _sample_index - if _sample_index is None and SampleIndex is not None: - try: - _sample_index = SampleIndex(SAMPLES_DIR) - except Exception as e: - logger.error(f"Error cargando índice de samples: {e}") - return _sample_index - - -def get_song_generator() -> Optional['SongGenerator']: - """Obtiene o crea el generador de canciones""" - global _song_generator - if _song_generator is None and SongGenerator is not None: - _song_generator = SongGenerator() - return _song_generator - - -# ============================================================================ -# LIFESPAN DEL SERVIDOR -# ============================================================================ - -@asynccontextmanager -async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: - """Maneja el ciclo de vida del servidor""" - try: - logger.info("=" * 60) - logger.info("AbletonMCP-AI Server v2 iniciando...") - logger.info("=" * 60) - - # Intentar conectar a Ableton - try: - ableton = get_ableton_connection() - if ableton.connect(): - logger.info("Conectado a Ableton Live") - else: - logger.warning("No se pudo conectar a Ableton (¿está abierto el script?)") - except Exception as e: - logger.warning(f"Error conectando a Ableton: {e}") - - # Inicializar conexión con Max - try: - get_max_connection() - logger.info(f"Conexión UDP con Max lista en puerto {DEFAULT_MAX_PORT}") - except Exception as e: - logger.warning(f"Error inicializando conexión con Max: {e}") - - # Inicializar índice de samples - try: - sample_index = get_sample_index() - if sample_index: - logger.info(f"Índice de samples cargado: {len(sample_index.samples)} samples") - else: - logger.warning("Índice de samples no disponible") - except Exception as e: - logger.warning(f"Error cargando índice de samples: {e}") - - # Inicializar generador de canciones - try: - song_gen = get_song_generator() - if song_gen: - logger.info("Generador de canciones listo") - else: - logger.warning("Generador de canciones no disponible") - except Exception as e: - logger.warning(f"Error inicializando generador: {e}") - - yield { - "ableton": _ableton_connection, - "max": _max_connection, - "samples": _sample_index, - "generator": _song_generator - } - - finally: - global _ableton_connection, _max_connection - if _ableton_connection: - logger.info("Desconectando de Ableton...") - _ableton_connection.disconnect() - if _max_connection and _max_connection.sock: - logger.info("Cerrando socket UDP...") - _max_connection.sock.close() - logger.info("AbletonMCP-AI Server v2 detenido") - - -# ============================================================================ -# CREAR SERVIDOR MCP -# ============================================================================ - -mcp = FastMCP( - "AbletonMCP-AI-v2", - instructions=PRODUCER_INSTRUCTIONS, - lifespan=server_lifespan -) - - -# ============================================================================ -# HERRAMIENTAS MCP - GENERACIÓN DE CANCIONES -# ============================================================================ - -@mcp.tool() -def generate_song( - ctx: Context, - genre: str = "house", - style: str = "", - bpm: float = 0, - key: str = "", - structure: str = "standard" -) -> str: - """ - Genera una canción completa con estructura profesional - - Args: - genre: Género musical (techno, house, trance, tech-house, drum-and-bass) - style: Sub-género o estilo específico (e.g., "industrial", "deep", "90s", "minimal") - bpm: BPM deseado (0 = auto-seleccionar según género) - key: Tonalidad (e.g., "Am", "F#m", "C") - vacío = auto-seleccionar - structure: Estructura del track (standard, minimal, extended) - - Returns: - Resumen de la canción generada - - Ejemplos: - generate_song("techno", "industrial", 138, "F#m") - generate_song("house", "deep", 124, "Am") - generate_song("tech-house", "groovy", 126) - """ - try: - generator = get_song_generator() - if not generator: - return "Error: Generador de canciones no disponible" - - ableton = get_ableton_connection() - if not ableton.connect(): - return f"Error: No se pudo conectar a Ableton en {ABLETON_HOST}:{DEFAULT_ABLETON_PORT}" - - # Generar configuración - config = generator.generate_config(genre, style, bpm, key, structure) - - # Enviar comando a Ableton - response = ableton.send_command("generate_complete_song", { - "genre": genre, - "style": style or config.get('style', ''), - "bpm": config.get('bpm', 120), - "key": config.get('key', ''), - "structure": structure - }) - - if response.get("status") == "success": - summary = config.get("summary", "") - return f"Canción generada exitosamente!\n{summary}" - else: - return f"Error generando canción: {response.get('message', 'Error desconocido')}" - - except Exception as e: - logger.exception("Error en generate_song") - return f"Error: {str(e)}" - - -@mcp.tool() -def load_sample_kit( - ctx: Context, - genre: str = "techno", - key: str = "", - bpm: int = 0 -) -> str: - """ - Carga un kit de samples completo para un género específico - - Args: - genre: Género musical para seleccionar samples apropiados - key: Tonalidad preferida para samples armónicos - bpm: BPM preferido para samples con tempo específico - - Returns: - Lista de samples cargados - """ - try: - sample_index = get_sample_index() - if not sample_index: - return "Error: Índice de samples no disponible" - - max_conn = get_max_connection() - - # Obtener pack de samples - kit = sample_index.get_sample_pack(genre, key, bpm) - - # Contar samples encontrados - total_samples = sum(len(samples) for samples in kit.values()) - - if total_samples == 0: - return f"No se encontraron samples para el género '{genre}'" - - # Enviar a Max - if max_conn.send_sample_kit(kit): - # Construir resumen - lines = [f"Kit de samples para {genre} cargado:", ""] - for category, samples in kit.items(): - if samples: - lines.append(f"{category.upper()}:") - for s in samples[:2]: # Mostrar máximo 2 por categoría - lines.append(f" - {s['name']}") - if len(samples) > 2: - lines.append(f" ... y {len(samples)-2} más") - lines.append("") - lines.append(f"Total: {total_samples} samples enviados a Max") - return "\n".join(lines) - else: - return "Error enviando kit a Max for Live" - - except Exception as e: - logger.exception("Error en load_sample_kit") - return f"Error: {str(e)}" - - -@mcp.tool() -def create_pattern( - ctx: Context, - instrument: str, - pattern_type: str = "standard", - track_index: int = -1, - clip_index: int = 0, - length: float = 4.0, - key: str = "Am", - genre: str = "techno" -) -> str: - """ - Crea un patrón MIDI para un instrumento específico - - Args: - instrument: Tipo de instrumento (kick, snare, hat, clap, bass, chords, lead, melody) - pattern_type: Tipo de patrón (standard, minimal, full, complex, simple) - track_index: Índice del track (-1 = crear nuevo) - clip_index: Índice del clip/slot - length: Duración en beats - key: Tonalidad para instrumentos melódicos - genre: Género para estilo del patrón - - Returns: - Confirmación del patrón creado - """ - try: - generator = get_song_generator() - if not generator: - return "Error: Generador no disponible" - - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - # Crear track si es necesario - if track_index < 0: - response = ableton.send_command("create_midi_track", {"index": -1}) - if response.get("status") == "success": - track_index = response.get("result", {}).get("index", 0) - else: - return "Error creando track MIDI" - - # Crear clip - clip_response = ableton.send_command("create_clip", { - "track_index": track_index, - "clip_index": clip_index, - "length": length - }) - - if clip_response.get("status") != "success": - return f"Error creando clip: {clip_response.get('message')}" - - # Generar notas según instrumento - notes = [] - color = TRACK_COLORS.get(instrument.lower(), 0) - - if instrument.lower() in ['kick', 'bd', 'bass drum']: - notes = generator._create_kick_pattern(genre, pattern_type) - elif instrument.lower() in ['snare', 'sd', 'clap']: - notes = generator._create_clap_pattern(genre, pattern_type) - elif instrument.lower() in ['hat', 'hihat', 'hh']: - notes = generator._create_hat_pattern(genre, pattern_type) - elif instrument.lower() in ['perc', 'percussion']: - notes = generator._create_perc_pattern(genre, pattern_type) - elif instrument.lower() == 'bass': - notes = generator.create_bassline(key, pattern_type, length) - elif instrument.lower() in ['chords', 'chord', 'pads']: - notes = generator.create_chord_progression(key, genre, length) - elif instrument.lower() in ['lead', 'melody', 'synth']: - notes = generator.create_melody(key, 'minor', length, genre) - else: - return f"Instrumento '{instrument}' no reconocido" - - # Aplicar color al track - if color: - ableton.send_command("set_track_color", { - "track_index": track_index, - "color": color - }) - - # Agregar notas - notes_response = ableton.send_command("add_notes_to_clip", { - "track_index": track_index, - "clip_index": clip_index, - "notes": notes - }) - - if notes_response.get("status") == "success": - return f"Patrón '{pattern_type}' para {instrument} creado en track {track_index}, clip {clip_index} ({len(notes)} notas)" - else: - return f"Error agregando notas: {notes_response.get('message')}" - - except Exception as e: - logger.exception("Error en create_pattern") - return f"Error: {str(e)}" - - -@mcp.tool() -def control_transport( - ctx: Context, - action: str, - tempo: float = None -) -> str: - """ - Controla el transporte de Ableton (play, stop, tempo) - - Args: - action: Acción a ejecutar (play, stop, continue, toggle, set_tempo) - tempo: BPM a establecer (solo para action='set_tempo') - - Returns: - Confirmación de la acción - """ - try: - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - action = action.lower() - - if action == "play": - response = ableton.send_command("start_playback") - if response.get("status") == "success": - return "Reproducción iniciada" - elif action == "stop": - response = ableton.send_command("stop_playback") - if response.get("status") == "success": - return "Reproducción detenida" - elif action == "continue": - response = ableton.send_command("continue_playback") - if response.get("status") == "success": - return "Reproducción continuada" - elif action in ["set_tempo", "tempo", "bpm"]: - if tempo is None or tempo <= 0: - return "Error: Debes especificar un tempo válido" - response = ableton.send_command("set_tempo", {"tempo": tempo}) - if response.get("status") == "success": - return f"Tempo establecido a {tempo} BPM" - elif action == "get_tempo": - response = ableton.send_command("get_session_info") - if response.get("status") == "success": - return f"Tempo actual: {response.get('result', {}).get('tempo', 'desconocido')} BPM" - else: - return f"Acción '{action}' no reconocida. Usa: play, stop, continue, set_tempo" - - return f"Error: {response.get('message', 'Error desconocido')}" - - except Exception as e: - logger.exception("Error en control_transport") - return f"Error: {str(e)}" - - -@mcp.tool() -def get_session_info(ctx: Context) -> str: - """ - Obtiene información completa de la sesión actual de Ableton - - Returns: - JSON con información de la sesión (tempo, tracks, estado de reproducción) - """ - try: - ableton = get_ableton_connection() - if not ableton.connect(): - return f"Error: No conectado a Ableton en {ABLETON_HOST}:{DEFAULT_ABLETON_PORT}" - - response = ableton.send_command("get_session_info") - - if response.get("status") == "success": - result = response.get("result", {}) - info_lines = [ - "Información de la sesión:", - f" Tempo: {result.get('tempo', 'N/A')} BPM", - f" Reproduciendo: {'Sí' if result.get('is_playing') else 'No'}", - f" Tracks: {result.get('num_tracks', 'N/A')}", - ] - if 'current_song_time' in result: - info_lines.append(f" Tiempo: {result.get('current_song_time')} beats") - return "\n".join(info_lines) - else: - return f"Error: {response.get('message', 'Error desconocido')}" - - except Exception as e: - logger.exception("Error en get_session_info") - return f"Error: {str(e)}" - - -# ============================================================================ -# HERRAMIENTAS MCP - GESTIÓN DE SAMPLES -# ============================================================================ - -@mcp.tool() -def search_samples( - ctx: Context, - query: str, - category: str = "", - limit: int = 10 -) -> str: - """ - Busca samples en la librería local - - Args: - query: Término de búsqueda (e.g., "kick", "bass", "hat") - category: Categoría (kick, snare, hat, bass, synth, percussion, vocal) - limit: Número máximo de resultados - - Returns: - Lista de samples encontrados - """ - try: - sample_index = get_sample_index() - if not sample_index: - return "Error: Índice de samples no disponible" - - results = sample_index.search(query, category, limit) - - if not results: - return f"No se encontraron samples para '{query}'" - - output = [f"Samples encontrados para '{query}':\n"] - for i, sample in enumerate(results, 1): - output.append(f"{i}. {sample['name']} ({sample['category']})") - output.append(f" Path: {sample['path']}") - if sample.get('key'): - output.append(f" Key: {sample['key']}, BPM: {sample.get('bpm', 'N/A')}") - output.append("") - - return "\n".join(output) - - except Exception as e: - logger.exception("Error en search_samples") - return f"Error: {str(e)}" - - -@mcp.tool() -def get_random_sample( - ctx: Context, - category: str = "" -) -> str: - """ - Obtiene un sample aleatorio de la librería - - Args: - category: Categoría opcional para filtrar - - Returns: - Información del sample seleccionado - """ - try: - sample_index = get_sample_index() - if not sample_index: - return "Error: Índice de samples no disponible" - - sample = sample_index.get_random_sample(category) - - if not sample: - return f"No hay samples disponibles{' en categoría ' + category if category else ''}" - - return f"""Sample aleatorio seleccionado: -Nombre: {sample['name']} -Categoría: {sample['category']} -Path: {sample['path']} -Key: {sample.get('key', 'N/A')} -BPM: {sample.get('bpm', 'N/A')}""" - - except Exception as e: - logger.exception("Error en get_random_sample") - return f"Error: {str(e)}" - - -@mcp.tool() -def send_sample_to_max( - ctx: Context, - sample_path: str, - track_index: int = 0, - slot: int = 0 -) -> str: - """ - Envía una ruta de sample a Max for Live para cargar - - Args: - sample_path: Ruta completa del archivo de audio - track_index: Índice del track donde cargar - slot: Slot/clip donde cargar el sample - - Returns: - Confirmación del envío - """ - try: - max_conn = get_max_connection() - - if max_conn.send_sample_path(track_index, sample_path, slot): - return f"Sample enviado a Max: {Path(sample_path).name} -> Track {track_index}, Slot {slot}" - else: - return "Error enviando sample a Max" - - except Exception as e: - logger.exception("Error en send_sample_to_max") - return f"Error: {str(e)}" - - -@mcp.tool() -def refresh_sample_index(ctx: Context) -> str: - """ - Refresca el índice de samples escaneando el directorio nuevamente - - Returns: - Confirmación con el número de samples encontrados - """ - try: - global _sample_index - if SampleIndex is None: - return "Error: Módulo SampleIndex no disponible" - - _sample_index = SampleIndex(SAMPLES_DIR) - _sample_index.refresh() - - return f"Índice refrescado: {len(_sample_index.samples)} samples encontrados" - - except Exception as e: - logger.exception("Error en refresh_sample_index") - return f"Error: {str(e)}" - - -# ============================================================================ -# HERRAMIENTAS MCP - CREACIÓN AVANZADA -# ============================================================================ - -@mcp.tool() -def create_drum_pattern( - ctx: Context, - track_index: int, - clip_index: int, - style: str = "techno", - pattern_type: str = "full", - length: float = 4.0 -) -> str: - """ - Crea un patrón de batería completo - - Args: - track_index: Índice del track MIDI donde crear el patrón - clip_index: Índice del clip/slot - style: Estilo (techno, house, trance, minimal) - pattern_type: Tipo de patrón (full, kick-only, hats-only, minimal) - length: Duración en beats - - Returns: - Confirmación del patrón creado - """ - try: - generator = get_song_generator() - if not generator: - return "Error: Generador no disponible" - - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - notes = generator.create_drum_pattern(style, pattern_type, length) - - # Crear clip - clip_response = ableton.send_command("create_clip", { - "track_index": track_index, - "clip_index": clip_index, - "length": length - }) - - if clip_response.get("status") != "success": - return f"Error creando clip: {clip_response.get('message')}" - - # Agregar notas - notes_response = ableton.send_command("add_notes_to_clip", { - "track_index": track_index, - "clip_index": clip_index, - "notes": notes - }) - - if notes_response.get("status") == "success": - return f"Patrón de batería '{style}' creado ({len(notes)} notas)" - else: - return f"Error agregando notas: {notes_response.get('message')}" - - except Exception as e: - logger.exception("Error en create_drum_pattern") - return f"Error: {str(e)}" - - -@mcp.tool() -def create_bassline( - ctx: Context, - track_index: int, - clip_index: int, - key: str, - style: str = "rolling", - length: float = 16.0 -) -> str: - """ - Crea una línea de bajo musical - - Args: - track_index: Índice del track MIDI - clip_index: Índice del clip - key: Tonalidad (e.g., "Am", "F#m", "C") - style: Estilo (rolling, minimal, acid, walking, offbeat) - length: Duración en beats - - Returns: - Confirmación del bassline creado - """ - try: - generator = get_song_generator() - if not generator: - return "Error: Generador no disponible" - - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - notes = generator.create_bassline(key, style, length) - - # Crear clip - clip_response = ableton.send_command("create_clip", { - "track_index": track_index, - "clip_index": clip_index, - "length": length - }) - - if clip_response.get("status") != "success": - return f"Error creando clip: {clip_response.get('message')}" - - # Agregar notas - notes_response = ableton.send_command("add_notes_to_clip", { - "track_index": track_index, - "clip_index": clip_index, - "notes": notes - }) - - if notes_response.get("status") == "success": - return f"Bassline '{style}' en {key} creado ({len(notes)} notas)" - else: - return f"Error agregando notas: {notes_response.get('message')}" - - except Exception as e: - logger.exception("Error en create_bassline") - return f"Error: {str(e)}" - - -@mcp.tool() -def create_chord_progression( - ctx: Context, - track_index: int, - clip_index: int, - key: str, - progression_type: str = "techno", - length: float = 16.0 -) -> str: - """ - Crea una progresión de acordes - - Args: - track_index: Índice del track MIDI - clip_index: Índice del clip - key: Tonalidad (e.g., "Am", "F#m", "C") - progression_type: Tipo (techno, house, deep, minor) - length: Duración en beats (usualmente 16 = 4 compases) - - Returns: - Confirmación de la progresión creada - """ - try: - generator = get_song_generator() - if not generator: - return "Error: Generador no disponible" - - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - notes = generator.create_chord_progression(key, progression_type, length) - - # Crear clip - clip_response = ableton.send_command("create_clip", { - "track_index": track_index, - "clip_index": clip_index, - "length": length - }) - - if clip_response.get("status") != "success": - return f"Error creando clip: {clip_response.get('message')}" - - # Agregar notas - notes_response = ableton.send_command("add_notes_to_clip", { - "track_index": track_index, - "clip_index": clip_index, - "notes": notes - }) - - if notes_response.get("status") == "success": - return f"Progresión '{progression_type}' en {key} creada ({len(notes)} notas)" - else: - return f"Error agregando notas: {notes_response.get('message')}" - - except Exception as e: - logger.exception("Error en create_chord_progression") - return f"Error: {str(e)}" - - -# ============================================================================ -# HERRAMIENTAS MCP - GESTIÓN DE TRACKS Y CLIPS -# ============================================================================ - -@mcp.tool() -def create_midi_track( - ctx: Context, - name: str = "MIDI Track", - color: int = None -) -> str: - """ - Crea un nuevo track MIDI - - Args: - name: Nombre del track - color: Color del track (0-69, opcional) - - Returns: - Confirmación con el índice del track creado - """ - try: - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - response = ableton.send_command("create_midi_track", {"index": -1}) - - if response.get("status") == "success": - track_index = response.get("result", {}).get("index", 0) - - # Setear nombre - ableton.send_command("set_track_name", { - "track_index": track_index, - "name": name - }) - - # Setear color si se especificó - if color is not None: - ableton.send_command("set_track_color", { - "track_index": track_index, - "color": color - }) - - return f"Track MIDI '{name}' creado en índice {track_index}" - else: - return f"Error: {response.get('message')}" - - except Exception as e: - logger.exception("Error en create_midi_track") - return f"Error: {str(e)}" - - -@mcp.tool() -def create_audio_track( - ctx: Context, - name: str = "Audio Track", - color: int = None -) -> str: - """ - Crea un nuevo track de audio - - Args: - name: Nombre del track - color: Color del track (0-69, opcional) - - Returns: - Confirmación con el índice del track creado - """ - try: - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - response = ableton.send_command("create_audio_track", {"index": -1}) - - if response.get("status") == "success": - track_index = response.get("result", {}).get("index", 0) - - # Setear nombre - ableton.send_command("set_track_name", { - "track_index": track_index, - "name": name - }) - - # Setear color si se especificó - if color is not None: - ableton.send_command("set_track_color", { - "track_index": track_index, - "color": color - }) - - return f"Track de audio '{name}' creado en índice {track_index}" - else: - return f"Error: {response.get('message')}" - - except Exception as e: - logger.exception("Error en create_audio_track") - return f"Error: {str(e)}" - - -@mcp.tool() -def set_track_volume( - ctx: Context, - track_index: int, - volume: float -) -> str: - """ - Ajusta el volumen de un track (0.0 - 1.0) - - Args: - track_index: Índice del track - volume: Volumen entre 0.0 y 1.0 - - Returns: - Confirmación del cambio - """ - try: - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - response = ableton.send_command("set_track_volume", { - "track_index": track_index, - "volume": volume - }) - - if response.get("status") == "success": - return f"Volumen del track {track_index} ajustado a {volume:.2f}" - else: - return f"Error: {response.get('message')}" - - except Exception as e: - logger.exception("Error en set_track_volume") - return f"Error: {str(e)}" - - -@mcp.tool() -def fire_clip( - ctx: Context, - track_index: int, - clip_index: int -) -> str: - """ - Dispara/reproduce un clip específico - - Args: - track_index: Índice del track - clip_index: Índice del clip/slot - - Returns: - Confirmación - """ - try: - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - response = ableton.send_command("fire_clip", { - "track_index": track_index, - "clip_index": clip_index - }) - - if response.get("status") == "success": - return f"Clip en track {track_index}, slot {clip_index} disparado" - else: - return f"Error: {response.get('message')}" - - except Exception as e: - logger.exception("Error en fire_clip") - return f"Error: {str(e)}" - - -@mcp.tool() -def fire_scene( - ctx: Context, - scene_index: int -) -> str: - """ - Dispara una scene (todos sus clips) - - Args: - scene_index: Índice de la scene - - Returns: - Confirmación - """ - try: - ableton = get_ableton_connection() - if not ableton.connect(): - return "Error: No conectado a Ableton" - - response = ableton.send_command("fire_scene", { - "scene_index": scene_index - }) - - if response.get("status") == "success": - return f"Scene {scene_index} disparada" - else: - return f"Error: {response.get('message')}" - - except Exception as e: - logger.exception("Error en fire_scene") - return f"Error: {str(e)}" - - -# ============================================================================ -# HERRAMIENTAS MCP - UTILIDADES -# ============================================================================ - -@mcp.tool() -def get_available_samples(ctx: Context) -> str: - """ - Obtiene un resumen de los samples disponibles en la librería - - Returns: - Resumen por categorías - """ - try: - sample_index = get_sample_index() - if not sample_index: - return "Error: Índice de samples no disponible" - - categories = {} - for sample in sample_index.samples: - cat = sample['category'] - categories[cat] = categories.get(cat, 0) + 1 - - lines = ["Samples disponibles:", ""] - for cat, count in sorted(categories.items(), key=lambda x: -x[1]): - lines.append(f" {cat}: {count}") - lines.append("") - lines.append(f"Total: {len(sample_index.samples)} samples") - - return "\n".join(lines) - - except Exception as e: - logger.exception("Error en get_available_samples") - return f"Error: {str(e)}" - - -@mcp.tool() -def test_connections(ctx: Context) -> str: - """ - Prueba las conexiones con Ableton y Max - - Returns: - Estado de las conexiones - """ - results = [] - - # Probar Ableton - try: - ableton = get_ableton_connection() - if ableton.connect(timeout=3.0): - results.append("Ableton: Conectado") - # Probar comando simple - resp = ableton.send_command("get_session_info") - if resp.get("status") == "success": - results.append(f" - Tempo: {resp.get('result', {}).get('tempo')} BPM") - results.append(f" - Tracks: {resp.get('result', {}).get('num_tracks')}") - else: - results.append(f"Ableton: No conectado ({ableton.last_error})") - except Exception as e: - results.append(f"Ableton: Error - {e}") - - # Probar Max - try: - max_conn = get_max_connection() - if max_conn.send_message({"type": "ping", "timestamp": datetime.now().isoformat()}): - results.append(f"Max for Live: Conexión UDP lista en puerto {DEFAULT_MAX_PORT}") - else: - results.append("Max for Live: Error enviando mensaje") - except Exception as e: - results.append(f"Max for Live: Error - {e}") - - # Probar Samples - try: - sample_index = get_sample_index() - if sample_index: - results.append(f"Samples: {len(sample_index.samples)} samples indexados") - else: - results.append("Samples: Índice no disponible") - except Exception as e: - results.append(f"Samples: Error - {e}") - - return "\n".join(results) - - -# ============================================================================ -# MAIN -# ============================================================================ - -def main(): - """Punto de entrada principal""" - import argparse - - parser = argparse.ArgumentParser(description="AbletonMCP-AI Server v2") - parser.add_argument("--port", type=int, default=0, help="Puerto para el servidor MCP (0 = auto)") - parser.add_argument("--transport", type=str, default="stdio", - choices=["stdio", "sse"], help="Transporte MCP") - parser.add_argument("--test", action="store_true", help="Probar conexiones y salir") - args = parser.parse_args() - - print("=" * 60) - print("AbletonMCP-AI Server v2") - print("=" * 60) - print(f"Transporte: {args.transport}") - print(f"Ableton: {ABLETON_HOST}:{DEFAULT_ABLETON_PORT}") - print(f"Max UDP: {MAX_HOST}:{DEFAULT_MAX_PORT}") - print(f"Samples: {SAMPLES_DIR}") - print("-" * 60) - - if args.test: - print("\nProbando conexiones...") - # Crear contexto temporal para test - ctx = Context(request_context={}) - result = test_connections(ctx) - print(result) - return - - # Iniciar servidor MCP - mcp.run(transport=args.transport) - - -if __name__ == "__main__": - main() diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/socket_smoke_test.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/socket_smoke_test.py deleted file mode 100644 index df16288..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/socket_smoke_test.py +++ /dev/null @@ -1,798 +0,0 @@ -import argparse -import json -import socket -from datetime import datetime -from typing import Any, Dict, List, Tuple - -try: - from song_generator import SongGenerator -except ImportError: - SongGenerator = None - - -STRUCTURE_SCENE_COUNTS = { - "minimal": 4, - "standard": 6, - "extended": 7, -} - -# Expected buses for Phase 7 validation -EXPECTED_BUSES = ["drums", "bass", "music", "vocal", "fx"] - -EXPECTED_CRITICAL_ROLES = {"kick", "bass", "clap", "hat"} - -EXPECTED_AUDIO_FX_LAYERS = ["AUDIO ATMOS", "AUDIO CRASH FX", "AUDIO TRANSITION FILL"] - -EXPECTED_BUS_NAMES = ["DRUMS", "BASS", "MUSIC"] - -MIN_TRACKS_FOR_EXPORT = 6 -MIN_BUSES_FOR_EXPORT = 3 -MIN_RETURNS_FOR_EXPORT = 2 -MASTER_VOLUME_RANGE = (0.75, 0.95) - -# Expected AUDIO RESAMPLE track names -AUDIO_RESAMPLE_TRACKS = [ - "AUDIO RESAMPLE REVERSE FX", - "AUDIO RESAMPLE RISER", - "AUDIO RESAMPLE DOWNLIFTER", - "AUDIO RESAMPLE STUTTER", -] - -# Bus routing map: track role -> expected bus output -BUS_ROUTING_MAP = { - "kick": {"drums"}, - "snare": {"drums"}, - "clap": {"drums"}, - "hat": {"drums"}, - "perc": {"drums"}, - "sub_bass": {"bass"}, - "bass": {"bass"}, - "chords": {"music"}, - "pad": {"music"}, - "pluck": {"music"}, - "lead": {"music"}, - "vocal": {"vocal"}, - "vocal_chop": {"vocal"}, - "reverse_fx": {"fx"}, - "riser": {"fx"}, - "impact": {"fx"}, - "atmos": {"fx"}, - "crash": {"drums", "fx"}, -} - - -def _extract_bus_payload(payload: Any) -> List[Dict[str, Any]]: - if isinstance(payload, list): - return [item for item in payload if isinstance(item, dict)] - if isinstance(payload, dict): - buses = payload.get("buses", []) - if isinstance(buses, list): - return [item for item in buses if isinstance(item, dict)] - return [] - - -def _normalize_bus_key(name: str) -> str: - normalized = "".join(ch for ch in (name or "").lower() if ch.isalnum()) - if not normalized: - return "" - if "drum" in normalized or "groove" in normalized: - return "drums" - if "bass" in normalized or "tube" in normalized or "subdeep" in normalized: - return "bass" - if "music" in normalized or "wide" in normalized: - return "music" - if "vocal" in normalized or "vox" in normalized or "tail" in normalized: - return "vocal" - if "fx" in normalized or "wash" in normalized: - return "fx" - return "" - - -def _canonical_track_name(name: str) -> str: - text = (name or "").strip().lower() - if not text: - return "" - if " (" in text: - text = text.split(" (", 1)[0].strip() - return text - - -class AbletonSocketClient: - def __init__(self, host: str = "127.0.0.1", port: int = 9877, timeout: float = 15.0): - self.host = host - self.port = port - self.timeout = timeout - - def send(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: - payload = json.dumps({ - "type": command_type, - "params": params or {}, - }).encode("utf-8") + b"\n" - - with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: - sock.sendall(payload) - reader = sock.makefile("r", encoding="utf-8") - try: - line = reader.readline() - finally: - reader.close() - try: - sock.shutdown(socket.SHUT_RDWR) - except OSError: - pass - - if not line: - raise RuntimeError(f"No response for command: {command_type}") - - return json.loads(line) - - -def expect_success(name: str, response: Dict[str, Any]) -> Dict[str, Any]: - if response.get("status") != "success": - raise RuntimeError(f"{name} failed: {response}") - return response.get("result", {}) - - -class TestResult: - """Tracks test results for reporting.""" - def __init__(self): - self.passed: List[Tuple[str, str]] = [] - self.failed: List[Tuple[str, str]] = [] - self.skipped: List[Tuple[str, str]] = [] - self.warnings: List[Tuple[str, str]] = [] - - def add_pass(self, name: str, details: str = ""): - self.passed.append((name, details)) - - def add_fail(self, name: str, error: str): - self.failed.append((name, error)) - - def add_skip(self, name: str, reason: str): - self.skipped.append((name, reason)) - - def add_warning(self, name: str, message: str): - self.warnings.append((name, message)) - - def to_dict(self) -> Dict[str, Any]: - return { - "summary": { - "total": len(self.passed) + len(self.failed) + len(self.skipped) + len(self.warnings), - "passed": len(self.passed), - "failed": len(self.failed), - "skipped": len(self.skipped), - "warnings": len(self.warnings), - "status": "PASS" if len(self.failed) == 0 else "FAIL", - }, - "passed_tests": [{"name": n, "details": d} for n, d in self.passed], - "failed_tests": [{"name": n, "error": d} for n, d in self.failed], - "skipped_tests": [{"name": n, "reason": d} for n, d in self.skipped], - "warnings": [{"name": n, "message": d} for n, d in self.warnings], - } - - def print_report(self): - print("\n" + "=" * 60) - print("PHASE 7 SMOKE TEST REPORT") - print("=" * 60) - print(f"Timestamp: {datetime.now().isoformat()}") - print(f"Total: {len(self.passed) + len(self.failed) + len(self.skipped) + len(self.warnings)}") - print(f"Passed: {len(self.passed)}") - print(f"Failed: {len(self.failed)}") - print(f"Skipped: {len(self.skipped)}") - print(f"Warnings: {len(self.warnings)}") - print("-" * 60) - - if self.passed: - print("\n[PASSED]") - for name, details in self.passed: - print(f" [OK] {name}: {details}") - - if self.failed: - print("\n[FAILED]") - for name, error in self.failed: - print(f" [FAIL] {name}: {error}") - - if self.warnings: - print("\n[WARNINGS]") - for name, message in self.warnings: - print(f" [WARN] {name}: {message}") - - if self.skipped: - print("\n[SKIPPED]") - for name, reason in self.skipped: - print(f" [SKIP] {name}: {reason}") - - print("\n" + "=" * 60) - status = "PASS" if len(self.failed) == 0 else "FAIL" - print(f"FINAL STATUS: {status}") - print("=" * 60 + "\n") - - -def run_readonly_checks(client: AbletonSocketClient) -> List[Tuple[str, str]]: - checks = [] - - expect_success("get_session_info", client.send("get_session_info")) - checks.append(( - "get_session_info", -# f"tempo={session.get('tempo')} tracks={session.get('num_tracks')} scenes={session.get('num_scenes')}", - )) - - tracks = expect_success("get_tracks", client.send("get_tracks")) - checks.append(("get_tracks", f"tracks={len(tracks)}")) - - return checks - - -def run_generation_check( - client: AbletonSocketClient, - genre: str, - style: str, - bpm: float, - key: str, - structure: str, - use_blueprint: bool = False, -) -> List[Tuple[str, str]]: - checks = [] - params = { - "genre": genre, - "style": style, - "bpm": bpm, - "key": key, - "structure": structure, - } - - if use_blueprint and SongGenerator is not None: - params = SongGenerator().generate_config(genre, style, bpm, key, structure) - - result = expect_success( - "generate_complete_song", - client.send("generate_complete_song", params), - ) - checks.append(( - "generate_complete_song", - f"tracks={result.get('tracks')} scenes={result.get('scenes')} structure={result.get('structure')}", - )) - - session = expect_success("post_generate_session_info", client.send("get_session_info")) - actual_scenes = session.get("num_scenes") - expected_scenes = len(params.get("sections", [])) if use_blueprint and isinstance(params, dict) and params.get("sections") else STRUCTURE_SCENE_COUNTS.get(structure.lower()) - if expected_scenes is not None and actual_scenes != expected_scenes: - raise RuntimeError( - f"scene count mismatch after generate_complete_song: expected {expected_scenes}, got {actual_scenes}" - ) - - checks.append(( - "post_generate_session_info", - f"tracks={session.get('num_tracks')} scenes={actual_scenes}", - )) - - return checks - - -def run_bus_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify buses are created correctly.""" - try: - buses_payload = expect_success("list_buses", client.send("list_buses")) - buses = _extract_bus_payload(buses_payload) - bus_keys = {_normalize_bus_key(bus.get("name", "")) for bus in buses} - bus_keys.discard("") - - found_buses = [] - missing_buses = [] - for expected in EXPECTED_BUSES: - if expected in bus_keys: - found_buses.append(expected) - else: - missing_buses.append(expected) - - if found_buses: - results.add_pass("buses_found", f"found={found_buses}") - - if missing_buses: - # Not a failure if buses don't exist yet - they may be created during generation - results.add_skip("buses_missing", f"not_found={missing_buses} (may be created during generation)") - else: - results.add_pass("buses_complete", "all expected buses present") - - except Exception as e: - results.add_fail("buses_check", str(e)) - - -def run_routing_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify track routing is configured correctly.""" - try: - tracks = expect_success("get_tracks", client.send("get_tracks")) - - if not tracks: - results.add_skip("routing_check", "no tracks to verify routing") - return - - correct_routing = 0 - incorrect_routing = [] - no_routing = 0 - - for track in tracks: - original_track_name = track.get("name", "") - track_name = _canonical_track_name(original_track_name) - output_routing = track.get("current_output_routing", "") - output_bus_key = _normalize_bus_key(output_routing) - track_bus_key = _normalize_bus_key(track_name) - - if output_routing and output_routing.lower() != "master": - correct_routing += 1 - elif not output_routing: - no_routing += 1 - - if track_bus_key: - continue - - for role, expected_bus in BUS_ROUTING_MAP.items(): - if role in track_name: - if output_bus_key in expected_bus: - correct_routing += 1 - elif output_routing.lower() != "master": - expected_label = "/".join(sorted(expected_bus)) - incorrect_routing.append(f"{original_track_name.lower()} -> {output_routing} (expected {expected_label})") - - results.add_pass("routing_summary", f"correct={correct_routing} no_routing={no_routing}") - - if incorrect_routing: - results.add_fail("routing_mismatches", ", ".join(incorrect_routing[:5])) - elif correct_routing > 0: - results.add_pass("routing_correct", f"{correct_routing} tracks with non-master routing") - - except Exception as e: - results.add_fail("routing_check", str(e)) - - -def run_audio_resample_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify AUDIO RESAMPLE tracks exist.""" - try: - tracks = expect_success("get_tracks", client.send("get_tracks")) - track_names = [t.get("name", "") for t in tracks] - - found_layers = [] - missing_layers = [] - - for expected in AUDIO_RESAMPLE_TRACKS: - if any(expected.upper() in name.upper() for name in track_names): - found_layers.append(expected) - else: - missing_layers.append(expected) - - if found_layers: - results.add_pass("audio_resample_found", f"layers={found_layers}") - - if missing_layers: - results.add_skip("audio_resample_missing", f"not_found={missing_layers} (may require reference audio)") - else: - results.add_pass("audio_resample_complete", "all 4 resample layers present") - - # Verify they are audio tracks - for track in tracks: - name = track.get("name", "").upper() - if "AUDIO RESAMPLE" in name: - if track.get("has_audio_input"): - results.add_pass(f"audio_track_type_{name[:20]}", "correct audio track type") - else: - results.add_fail(f"audio_track_type_{name[:20]}", "expected audio track") - - except Exception as e: - results.add_fail("audio_resample_check", str(e)) - - -def run_automation_snapshot_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify automation and device parameter snapshots.""" - try: - tracks = expect_success("get_tracks", client.send("get_tracks")) - - total_devices = 0 - tracks_with_devices = 0 - tracks_with_automation = 0 - - for track in tracks: - num_devices = track.get("num_devices", 0) - if num_devices > 0: - total_devices += num_devices - tracks_with_devices += 1 - - # Check for arrangement clips (may contain automation) - arrangement_clips = track.get("arrangement_clip_count", 0) - if arrangement_clips > 0: - tracks_with_automation += 1 - - if tracks_with_devices > 0: - results.add_pass("automation_devices", f"tracks_with_devices={tracks_with_devices} total_devices={total_devices}") - else: - results.add_skip("automation_devices", "no devices found") - - if tracks_with_automation > 0: - results.add_pass("automation_clips", f"tracks_with_arrangement_clips={tracks_with_automation}") - else: - results.add_skip("automation_clips", "no arrangement clips (may need to commit to arrangement)") - - # Try to get device parameters for first track with devices - for i, track in enumerate(tracks): - if track.get("num_devices", 0) > 0: - try: - devices = expect_success("get_devices", client.send("get_devices", {"track_index": i})) - if devices: - params_sample = [] - for dev in devices[:3]: - params = dev.get("parameters", []) - if params: - params_sample.append(f"{dev.get('name', '?')}:{len(params)}params") - if params_sample: - results.add_pass("automation_params_snapshot", ", ".join(params_sample[:3])) - break - except Exception: - pass - break - - except Exception as e: - results.add_fail("automation_snapshot_check", str(e)) - - -def run_loudness_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify basic loudness levels using output meters.""" - try: - tracks = expect_success("get_tracks", client.send("get_tracks")) - - tracks_with_signal = 0 - max_level = 0.0 - level_samples = [] - - for track in tracks: - output_level = track.get("output_meter_level", 0.0) - left = track.get("output_meter_left", 0.0) - right = track.get("output_meter_right", 0.0) - - if output_level and output_level > 0: - tracks_with_signal += 1 - max_level = max(max_level, output_level) - level_samples.append(f"{track.get('name', '?')[:15]}:{output_level:.2f}") - - # Check for stereo balance - if left and right and left > 0 and right > 0: - balance = abs(left - right) - if balance < 0.1: - pass # Balanced stereo - - if tracks_with_signal > 0: - results.add_pass("loudness_signal_detected", f"tracks_with_signal={tracks_with_signal} max_level={max_level:.3f}") - else: - results.add_skip("loudness_signal", "no signal detected (playback may be stopped)") - - # Check for clipping (levels > 1.0) - if max_level > 1.0: - results.add_fail("loudness_clipping", f"max_level={max_level:.3f} indicates potential clipping") - else: - results.add_pass("loudness_no_clipping", f"max_level={max_level:.3f}") - - # Sample levels for verification - if level_samples: - results.add_pass("loudness_levels", ", ".join(level_samples[:5])) - - except Exception as e: - results.add_fail("loudness_check", str(e)) - - -def run_critical_layer_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify critical layers (kick, bass, clap, hat) exist and have content.""" - try: - tracks = expect_success("get_tracks", client.send("get_tracks")) - track_names = [str(t.get("name", "")).upper() for t in tracks if isinstance(t, dict)] - - found_layers = {role: False for role in EXPECTED_CRITICAL_ROLES} - for track_name in track_names: - for role in EXPECTED_CRITICAL_ROLES: - if role.upper() in track_name or f"AUDIO {role.upper()}" in track_name: - found_layers[role] = True - break - - for role, found in found_layers.items(): - if found: - results.add_pass(f"critical_layer_{role}", "found in tracks") - else: - results.add_fail(f"critical_layer_{role}", "missing - set may sound incomplete") - except Exception as e: - results.add_fail("critical_layer_check", str(e)) - - -def run_derived_fx_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify derived FX tracks (AUDIO RESAMPLE) are present.""" - try: - tracks = expect_success("get_tracks", client.send("get_tracks")) - track_names = [str(t.get("name", "")).upper() for t in tracks if isinstance(t, dict)] - - found_derived = [] - missing_derived = [] - for expected in AUDIO_RESAMPLE_TRACKS: - if any(expected.upper() in name for name in track_names): - found_derived.append(expected) - else: - missing_derived.append(expected) - - if found_derived: - results.add_pass("derived_fx_found", f"layers={found_derived}") - - if missing_derived: - results.add_skip("derived_fx_missing", f"not_found={missing_derived} (may require reference audio)") - else: - results.add_pass("derived_fx_complete", "all 4 resample layers present") - - except Exception as e: - results.add_fail("derived_fx_check", str(e)) - - -def run_export_readiness_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify set is ready for export.""" - try: - expect_success("get_session_info", client.send("get_session_info")) - tracks = expect_success("get_tracks", client.send("get_tracks")) - - issues = [] - - track_count = len(tracks) if isinstance(tracks, list) else 0 - if track_count < MIN_TRACKS_FOR_EXPORT: - issues.append(f"insufficient_tracks: {track_count} (need {MIN_TRACKS_FOR_EXPORT}+)") - - master_response = client.send("get_track_info", {"track_type": "master", "track_index": 0}) - if master_response.get("status") == "success": - master_volume = float(master_response.get("result", {}).get("volume", 0.85)) - if master_volume < MASTER_VOLUME_RANGE[0]: - issues.append(f"master_volume_low: {master_volume:.2f}") - elif master_volume > MASTER_VOLUME_RANGE[1]: - issues.append(f"master_volume_high: {master_volume:.2f}") - - muted_count = sum(1 for t in tracks if isinstance(t, dict) and t.get("mute", False)) - if muted_count > track_count * 0.5: - issues.append(f"too_many_muted: {muted_count}/{track_count}") - - if issues: - results.add_pass("export_readiness_issues", f"issues={len(issues)}") - for issue in issues: - results.add_fail(f"export_ready_{issue.split(':')[0]}", issue) - else: - results.add_pass("export_ready", "set appears ready for export") - - except Exception as e: - results.add_fail("export_readiness_check", str(e)) - - -def run_midi_clip_content_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify MIDI tracks have clips with notes.""" - try: - tracks = expect_success("get_tracks", client.send("get_tracks")) - - midi_tracks_empty = [] - midi_tracks_with_notes = 0 - - for track in tracks: - if not isinstance(track, dict): - continue - track_type = str(track.get("type", "")).lower() - if track_type != "midi": - continue - - track_name = track.get("name", "?") - clips = track.get("clips", []) - if not isinstance(clips, list): - clips = [] - - has_notes = False - empty_clips = [] - for clip in clips: - if not isinstance(clip, dict): - continue - notes_count = clip.get("notes_count", 0) - has_notes_flag = clip.get("has_notes", None) - if has_notes_flag is True or notes_count > 0: - has_notes = True - elif has_notes_flag is False or (has_notes_flag is None and notes_count == 0): - empty_clips.append(clip.get("name", "?")) - if has_notes: - midi_tracks_with_notes += 1 - elif empty_clips: - midi_tracks_empty.append({ - "track_name": track_name, - "empty_clips_count": len(empty_clips), - }) - - if midi_tracks_with_notes > 0: - results.add_pass("midi_tracks_with_notes", f"count={midi_tracks_with_notes}") - - if midi_tracks_empty: - for track_info in midi_tracks_empty[:3]: - results.add_fail( - f"midi_track_empty_{track_info['track_name'][:20]}", - f"Track has {track_info['empty_clips_count']} empty MIDI clips - may need notes" - ) - - except Exception as e: - results.add_fail("midi_clip_content_check", str(e)) - - -def run_bus_signal_checks(client: AbletonSocketClient, results: TestResult) -> None: - """Verify buses receive signal from tracks.""" - try: - buses_payload = expect_success("list_buses", client.send("list_buses")) - buses = _extract_bus_payload(buses_payload) - tracks = expect_success("get_tracks", client.send("get_tracks")) - - bus_signal_map = {} - for bus in buses: - if not isinstance(bus, dict): - continue - bus_name = bus.get("name", "").upper() - bus_signal_map[bus_name] = {"senders": [], "has_signal": False} - - for track in tracks: - if not isinstance(track, dict): - continue - track_name = str(track.get("name", "")).upper() - output_routing = str(track.get("current_output_routing", "")).upper() - - for bus_name in bus_signal_map: - if bus_name in output_routing: - bus_signal_map[bus_name]["senders"].append(track_name) - - sends = track.get("sends", []) - if isinstance(sends, list): - for send_level in sends: - try: - if float(send_level) > 0.01: - pass - except (TypeError, ValueError): - pass - - buses_without_senders = [] - buses_with_senders = [] - - for bus_name, info in bus_signal_map.items(): - if info["senders"]: - buses_with_senders.append(bus_name) - else: - buses_without_senders.append(bus_name) - - if buses_with_senders: - results.add_pass("buses_with_signal", f"buses={buses_with_senders}") - - if buses_without_senders: - for bus_name in buses_without_senders[:3]: - results.add_fail(f"bus_no_signal_{bus_name[:15]}", - f"Bus '{bus_name}' has no routed tracks - will not produce output") - - except Exception as e: - results.add_fail("bus_signal_check", str(e)) - - -def run_clipping_detection(client: AbletonSocketClient, results: TestResult) -> None: - """Detect tracks with dangerously high volume (clipping risk).""" - try: - tracks = expect_success("get_tracks", client.send("get_tracks")) - - clipping_tracks = [] - high_volume_tracks = [] - - for track in tracks: - if not isinstance(track, dict): - continue - track_name = track.get("name", "?") - volume = float(track.get("volume", 0.85)) - - if volume > 0.95: - clipping_tracks.append({"name": track_name, "volume": volume}) - elif volume > 0.90: - high_volume_tracks.append({"name": track_name, "volume": volume}) - - if clipping_tracks: - for track_info in clipping_tracks[:3]: - results.add_fail(f"clipping_track_{track_info['name'][:15]}",f"Volume {track_info['volume']:.2f} > 0.95 - CLIPPING RISK") - - if high_volume_tracks: - for track_info in high_volume_tracks[:3]: - results.add_warning(f"high_volume_{track_info['name'][:15]}", - f"Volume {track_info['volume']:.2f} - consider reducing") - - if not clipping_tracks and not high_volume_tracks: - results.add_pass("no_clipping_tracks", "All track volumes in safe range") - - except Exception as e: - results.add_fail("clipping_detection", str(e)) - - -def run_all_phase7_tests(client: AbletonSocketClient, results: TestResult) -> None: - """Run all Phase 7 smoke tests.""" - print("\n[Phase 7] Running bus verification...") - run_bus_checks(client, results) - - print("[Phase 7] Running routing verification...") - run_routing_checks(client, results) - - print("[Phase 7] Running AUDIO RESAMPLE track verification...") - run_audio_resample_checks(client, results) - - print("[Phase 7] Running automation snapshot verification...") - run_automation_snapshot_checks(client, results) - - print("[Phase 7] Running loudness verification...") - run_loudness_checks(client, results) - - print("[Phase 7] Running critical layer verification...") - run_critical_layer_checks(client, results) - - print("[Phase 7] Running derived FX verification...") - run_derived_fx_checks(client, results) - - print("[Phase 7] Running export readiness verification...") - run_export_readiness_checks(client, results) - - print("[Phase 7] Running MIDI clip content verification...") - run_midi_clip_content_checks(client, results) - - print("[Phase 7] Running bus signal verification...") - run_bus_signal_checks(client, results) - - print("[Phase 7] Running clipping detection...") - run_clipping_detection(client, results) - - -def main() -> int: - parser = argparse.ArgumentParser(description="Smoke test for AbletonMCP_AI socket runtime") - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=9877) - parser.add_argument("--timeout", type=float, default=15.0) - parser.add_argument("--generate-demo", action="store_true") - parser.add_argument("--genre", default="techno") - parser.add_argument("--style", default="industrial") - parser.add_argument("--bpm", type=float, default=128.0) - parser.add_argument("--key", default="Am") - parser.add_argument("--structure", default="standard") - parser.add_argument("--use-blueprint", action="store_true") - parser.add_argument("--phase7", action="store_true", help="Run Phase 7 extended tests (buses, routing, audio resample, automation, loudness)") - parser.add_argument("--json-report", action="store_true", help="Output report as JSON") - args = parser.parse_args() - - client = AbletonSocketClient(host=args.host, port=args.port, timeout=args.timeout) - - # Run basic checks - print("[Basic] Running readonly checks...") - checks = run_readonly_checks(client) - - for name, details in checks: - print(f"[ok] {name}: {details}") - - # Run generation check if requested - if args.generate_demo: - print("\n[Generation] Running generation check...") - checks.extend( - run_generation_check( - client, - genre=args.genre, - style=args.style, - bpm=args.bpm, - key=args.key, - structure=args.structure, - use_blueprint=args.use_blueprint, - ) - ) - for name, details in checks[-2:]: - print(f"[ok] {name}: {details}") - - # Run Phase 7 tests if requested - results = TestResult() - if args.phase7: - run_all_phase7_tests(client, results) - - if args.json_report: - print(json.dumps(results.to_dict(), indent=2)) - else: - results.print_report() - - return 0 if len(results.failed) == 0 else 1 - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/song_generator.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/song_generator.py deleted file mode 100644 index 141f1fc..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/song_generator.py +++ /dev/null @@ -1,6268 +0,0 @@ -""" -song_generator.py - Generador musical para AbletonMCP-AI. -""" - -import random -import logging -from typing import List, Dict, Any, Optional, Union, Tuple -from dataclasses import dataclass -from pathlib import Path -from collections import defaultdict - -logger = logging.getLogger("SongGenerator") - -# Notas MIDI para referencia -NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] - -# Escalas comunes (semitonos desde la raíz) -SCALES = { - '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], - 'dorian': [0, 2, 3, 5, 7, 9, 10], - 'phrygian': [0, 1, 3, 5, 7, 8, 10], - 'mixolydian': [0, 2, 4, 5, 7, 9, 10], - 'pentatonic_minor': [0, 3, 5, 7, 10], - 'pentatonic_major': [0, 2, 4, 7, 9], - 'blues': [0, 3, 5, 6, 7, 10], -} - -# Progresiones de acordes comunes -CHORD_PROGRESSIONS = { - 'techno': [ - [1, 1, 1, 1], # i - i - i - i (minimal) - [1, 6, 1, 6], # i - VI - i - VI - [1, 4, 1, 4], # i - iv - i - iv - [1, 7, 6, 7], # i - VII - VI - VII - ], - 'house': [ - [1, 5, 6, 4], # I - V - vi - IV (pop house) - [1, 4, 5, 1], # I - IV - V - I - [6, 4, 1, 5], # vi - IV - I - V - [1, 6, 4, 5], # I - vi - IV - V - ], - 'deep': [ - [1, 6, 2, 5], # i - VI - ii - V - [2, 5, 1, 6], # ii - V - i - VI - ], - 'tech-house': [ - [1, 6, 3, 6], # i - VI - III - VI (dark, hypnotic: Am -> Fm -> Cm -> Fm) - [1, 5, 1, 5], # i - v - i - v (two-chord drop loop: Dm -> Am -> Dm -> Am) - [1, 5, 6, 5], # i - v - VI - v (minimalist tension: Cm -> Gm -> Ab -> Gm) - [1, 4, 1, 4], # i - iv - i - iv (groovy: Am -> Dm -> Am -> Dm) - ], - 'trance': [ - [1, 5, 6, 4], # I - V - vi - IV - [6, 4, 1, 5], # vi - IV - I - V - [1, 4, 6, 5], # I - IV - vi - V - ], -} - -# Configuraciones por género -GENRE_CONFIGS = { - 'techno': { - 'bpm_range': (125, 140), - 'default_bpm': 132, - 'keys': ['Am', 'Fm', 'Dm', 'G#m', 'Cm'], - 'styles': ['industrial', 'peak-time', 'dub', 'minimal', 'acid'], - }, - 'house': { - 'bpm_range': (120, 128), - 'default_bpm': 124, - 'keys': ['Am', 'Em', 'Cm', 'Gm', 'Dm', 'F#m'], - 'styles': ['deep', 'tech-house', 'progressive', 'afro', 'classic', 'funky'], - }, - 'tech-house': { - 'bpm_range': (122, 128), - 'default_bpm': 126, - 'keys': ['Am', 'Fm', 'Dm', 'Gm', 'Cm'], - 'styles': ['groovy', 'bouncy', 'minimal', 'latin', 'latin-industrial', 'jackin', 'swing', 'latin-tech-house'], - }, - 'trance': { - 'bpm_range': (135, 150), - 'default_bpm': 140, - 'keys': ['Fm', 'Am', 'Dm', 'Gm', 'Cm'], - 'styles': ['progressive', 'uplifting', 'psy', 'acid'], - }, - 'drum-and-bass': { - 'bpm_range': (160, 180), - 'default_bpm': 174, - 'keys': ['Am', 'Fm', 'Gm', 'Cm'], - 'styles': ['liquid', 'neuro', 'jump-up', 'jungle'], - }, -} - -# Colores por tipo de track -TRACK_COLORS = { - 'kick': 10, # Rojo - 'snare': 20, # Verde - 'hat': 5, # Amarillo - 'clap': 45, # Naranja - 'bass': 30, # Azul - 'synth': 50, # Rosa/Magenta - 'chords': 60, # Púrpura - 'fx': 25, # Verde claro - 'vocal': 15, # Naranja oscuro - 'pad': 55, # Purpura claro - 'perc': 20, # Verde - 'ride': 14, # Amarillo oscuro - 'technical': 58, # Gris -} - -BUS_TRACK_COLORS = { - 'drums': 10, - 'bass': 30, - 'music': 50, - 'vocal': 15, - 'fx': 25, - 'sc_trigger': 58, # Gris - track fantasma para sidechain -} - -# NTH-04: Genre-specific color palettes for visual consistency -GENRE_COLOR_PALETTES = { - 'tech-house': { - 'kick': 13, 'clap': 11, 'snare': 11, 'hat': 12, - 'bass': 35, 'sub_bass': 33, - 'synth': 53, 'chords': 51, 'pad': 55, 'pluck': 50, - 'fx': 21, 'vocal': 17, 'perc': 20, 'ride': 14, - 'technical': 58, - }, - 'techno': { - 'kick': 0, 'clap': 5, 'hat': 3, - 'bass': 30, 'synth': 45, - 'fx': 25, 'perc': 20, 'technical': 58, - }, - 'house': { - 'kick': 10, 'clap': 15, 'hat': 20, - 'bass': 34, 'synth': 50, 'chords': 55, - 'fx': 25, 'vocal': 40, 'perc': 20, 'technical': 58, - }, -} - -# Configuracion de sidechain por bus -# Cada bus puede tener sidechain desde SC TRIGGER -BUS_SIDECHAIN_CONFIG = { - 'drums': { - 'enabled': False, # Drums no suele necesitar sidechain - 'threshold': -18.0, - 'attack': 0.003, - 'release': 0.08, - 'ratio': 4.0, - }, - 'bass': { - 'enabled': True, # Sidechain clave para bass - 'threshold': -22.0, - 'attack': 0.002, - 'release': 0.12, - 'ratio': 4.5, - }, - 'music': { - 'enabled': True, # Sidechain sutil para musica - 'threshold': -26.0, - 'attack': 0.005, - 'release': 0.18, - 'ratio': 3.0, - }, - 'vocal': { - 'enabled': True, # Sidechain suave para vocal - 'threshold': -28.0, - 'attack': 0.008, - 'release': 0.22, - 'ratio': 2.5, - }, - 'fx': { - 'enabled': False, # FX generalmente sin sidechain - 'threshold': -30.0, - 'attack': 0.01, - 'release': 0.3, - 'ratio': 2.0, - }, -} - -# ============================================================================= -# FASE 3: LOUDNESS CONSISTENCY Y GAIN STAGING -# ============================================================================= -# -# CALIBRATION PHILOSOPHY: -# ====================== -# - Kick sits at unity (0.85) as the rhythmic anchor -# - Bass sits slightly below kick (-1dB) for low-end presence without mud -# - Supporting elements progressively lower to create mix depth -# - Buses attenuated to preserve master headroom -# - Master chain with soft limiting for consistent output -# -# HEADROOM TARGETS: -# ================= -# - Track peaks: -6dB to -3dB before bus -# - Bus peaks: -3dB to -1dB before master -# - Master out: -1dB peak (limited), integrated LUFS ~-10 to -8 - -# Headroom target en dB (negativo para dejar espacio antes del limiter) -TARGET_HEADROOM_DB = -1.5 # 1.5dB de headroom antes del limiter - -# Safe limiting threshold - prevents digital clipping -MASTER_LIMITER_CEILING_DB = -0.3 # Never go above -0.3dBFS on master - -# Calibracion de ganancia por bus (valores lineales 0.0-1.0) -# Calibrado empiricamente para headroom consistente y balance de mezcla -# K: Drums como elemento principal, B: Bass como soporte, M: Music como capa -BUS_GAIN_CALIBRATION = { - 'drums': { - 'volume': 0.92, # Drums bus: principal, mas alto - 'limiter_gain': 0.0, # Sin gain adicional en limiter de bus - 'compressor_threshold': -16.0, # Compression suave para punch - 'saturator_drive': 0.6, # armonia sutil, no crunchy - 'utility_gain': 0.0, # Sin gain adicional - }, - 'bass': { - 'volume': 0.88, # Bass bus: soporte fuerte - 'limiter_gain': 0.0, # Sin limiter en bass bus (soft clip natural) - 'compressor_threshold': -18.0, # Threshold suave para low-end - 'saturator_drive': 0.4, # Saturacion sutil - evitar crunch - 'utility_gain': 0.0, # Sin gain adicional - }, - 'music': { - 'volume': 0.85, # Music bus: capa principal - 'limiter_gain': 0.0, # Sin limiter en music bus - 'compressor_threshold': -20.0, # Preservar transients - 'saturator_drive': 0.0, # Sin saturacion en bus de musica - 'utility_gain': 0.0, - }, - 'vocal': { - 'volume': 0.82, # Vocal bus: presente en mezcla - 'limiter_gain': 0.0, # Sin limiter - 'compressor_threshold': -16.0, # Compresion sutil para presencia - 'saturator_drive': 0.0, - 'utility_gain': 0.0, - }, - 'fx': { - 'volume': 0.78, # FX bus: efectos audibles - 'limiter_gain': 0.0, # Sin gain - 'compressor_threshold': -22.0, # Preservar dynamics - 'saturator_drive': 0.0, - 'utility_gain': 0.0, # Sin reduccion - }, - 'sc_trigger': { - 'volume': 0.0, # Track fantasma - sin audio - 'limiter_gain': 0.0, - 'compressor_threshold': 0.0, - 'saturator_drive': 0.0, - 'utility_gain': 0.0, - }, -} - -# Master chain calibracion -# Calibrado para LUFS ~-8 a -10dB con headroom de 1-2dB antes del limiter -# El limiter ceiling esta en -0.3dB para evitar digital clipping -MASTER_CALIBRATION = { - 'default': { - 'volume': 0.85, # Master at ~0dB de ganancia interna - 'utility_gain': 0.0, # Sin reduccion - volumen completo - 'stereo_width': 1.04, # Ligerisimo widening - 'saturator_drive': 0.12, # Saturacion muy sutil en master - 'compressor_ratio': 0.50, # Compresion suave (glue, no squash) - 'compressor_attack': 0.30, # Attack lento para preservar transients - 'compressor_release': 0.20, - 'limiter_gain': 3.5, # +3.5dB make-up gain para nivel moderno - 'limiter_ceiling': -0.3, # Ceiling a -0.3dBFS (safe limiting) - }, - 'warehouse': { - 'volume': 0.85, - 'utility_gain': 0.0, # Sin reduccion - 'saturator_drive': 0.25, # Mas drive para industrial techno - 'compressor_ratio': 0.55, # Un poco mas de compresion - 'limiter_gain': 3.8, # Mas gain para industrial - 'limiter_ceiling': -0.3, - }, - 'festival': { - 'volume': 0.86, - 'utility_gain': 0.0, # Sin reduccion - 'stereo_width': 1.06, # Mas ancho para festival - 'limiter_gain': 4.0, # Maximo gain para festival - 'limiter_ceiling': -0.3, - }, - 'swing': { - 'volume': 0.85, - 'utility_gain': 0.0, - 'saturator_drive': 0.15, # Moderado - 'limiter_gain': 3.2, - 'limiter_ceiling': -0.3, - }, - 'jackin': { - 'volume': 0.85, - 'utility_gain': 0.0, - 'compressor_ratio': 0.52, - 'limiter_gain': 3.0, - 'limiter_ceiling': -0.3, - }, - 'tech-house-club': { - 'volume': 0.85, - 'utility_gain': 0.0, # Sin reduccion - 'stereo_width': 1.04, - 'saturator_drive': 0.4, # Mas drive para punch - 'compressor_ratio': 0.60, # Mas compresion para club - 'compressor_attack': 0.28, - 'limiter_gain': 3.5, - 'limiter_ceiling': -0.3, - }, - 'tech-house-deep': { - 'volume': 0.85, - 'utility_gain': 0.0, # Sin reduccion - 'stereo_width': 1.02, # Narrower para deep - 'saturator_drive': 0.1, # Muy sutil - 'compressor_ratio': 0.50, - 'compressor_attack': 0.38, # Mas lento para deep - 'limiter_gain': 3.0, - 'limiter_ceiling': -0.3, - }, - 'tech-house-funky': { - 'volume': 0.85, - 'utility_gain': 0.0, - 'stereo_width': 1.08, # Wide para groove - 'saturator_drive': 0.3, - 'compressor_ratio': 0.55, - 'compressor_attack': 0.30, - 'limiter_gain': 3.5, - 'limiter_ceiling': -0.3, - }, -} - -# Calibracion de gain por rol para consistencia de mezcla -# Valores calibrados empiricamente basados en: -# - Kick como ancla a 0.85 -# - Bass -1dB relativo a kick -# - Elementos de soporte progresivamente mas bajos -# - Headroom preservado en cada capa -ROLE_GAIN_CALIBRATION = { - # DRUMS - Kick es el ancla, otros elementos debajo - 'kick': { - 'volume': 0.85, # Ancla: 0dB relativo, elemento principal - 'saturator_drive': 1.5, # Saturacion sutil para punch - 'peak_reduction': 0.0, # Sin reduccion - es el ancla - }, - 'clap': { - 'volume': 0.78, # -1.5dB relativo a kick - 'saturator_drive': 0.0, # Sin saturacion - 'peak_reduction': 0.0, - }, - 'snare_fill': { - 'volume': 0.72, # -3dB, transitorio fuerte - 'peak_reduction': 0.0, - }, - 'hat_closed': { - 'volume': 0.68, # -4dB, elemento secundario - 'peak_reduction': 0.0, - }, - 'hat_open': { - 'volume': 0.65, # -4.5dB, mas abajo por sustain - 'peak_reduction': 0.0, - }, - 'top_loop': { - 'volume': 0.62, # -5dB, capa ritmica secundaria - 'peak_reduction': 0.0, - }, - 'perc': { - 'volume': 0.70, # -3.5dB, soporte ritmico - 'peak_reduction': 0.0, - }, - 'ride': { - 'volume': 0.58, # -5.5dB, sustain largo - 'peak_reduction': 0.0, - }, - 'crash': { - 'volume': 0.50, # -7dB, transitorio largo - 'peak_reduction': 0.0, - }, - 'tom_fill': { - 'volume': 0.68, # -4dB, transitorio - 'peak_reduction': 0.0, - }, - # BASS - Underground but underneath drums - 'sub_bass': { - 'volume': 0.80, # -0.5dB relativo a kick - 'saturator_drive': 0.0, # Sin saturacion en sub - 'peak_reduction': 0.0, - }, - 'bass': { - 'volume': 0.78, # -1dB relativo a kick - 'saturator_drive': 2.0, # Moderado para harmonic content - 'peak_reduction': 0.0, - }, - # MUSIC - Capas de soporte, debajo del low-end - 'drone': { - 'volume': 0.55, # -7dB, elemento de fondo - 'peak_reduction': 0.0, - }, - 'chords': { - 'volume': 0.70, # -3dB, armonia principal - 'peak_reduction': 0.0, - }, - 'stab': { - 'volume': 0.65, # -4dB, transitorio - 'saturator_drive': 1.8, # Moderado - 'peak_reduction': 0.0, - }, - 'pad': { - 'volume': 0.60, # -5dB, fondo armonico - 'peak_reduction': 0.0, - }, - 'pluck': { - 'volume': 0.68, # -3.5dB, melodia sutil - 'peak_reduction': 0.0, - }, - 'arp': { - 'volume': 0.65, # -4dB, movimiento armonico - 'peak_reduction': 0.0, - }, - 'lead': { - 'volume': 0.72, # -2.5dB, elemento principal musical - 'saturator_drive': 1.2, # Moderado - 'peak_reduction': 0.0, - }, - 'counter': { - 'volume': 0.62, # -5dB, contramelodia - 'peak_reduction': 0.0, - }, - # FX - Efectos en el fondo de la mezcla - 'reverse_fx': { - 'volume': 0.52, # -7dB, efecto ambiente - 'peak_reduction': 0.0, - }, - 'riser': { - 'volume': 0.60, # -5dB, sube hacia el climax - 'peak_reduction': 0.0, - }, - 'impact': { - 'volume': 0.55, # -6dB, efecto puntual - 'peak_reduction': 0.0, - }, - 'atmos': { - 'volume': 0.50, # -8dB, fondo atmosferico - 'peak_reduction': 0.0, - }, - # VOCAL - 'vocal': { - 'volume': 0.70, # -3dB, debajo de drums pero presente - 'peak_reduction': 0.0, - }, - # SC TRIGGER - Track fantasma para sidechain - 'sc_trigger': { - 'volume': 0.0, # Sin salida de audio - 'saturator_drive': 0.0, - 'peak_reduction': 0.0, - }, -} - -# Factores de ajuste por estilo -# NOTA: NO usar multiplicadores de volumen que rompan el gain staging -# Solo ajustes sutiles de procesamiento y sends -STYLE_GAIN_ADJUSTMENTS = { - 'industrial': { - 'saturator_drive_factor': 1.3, # Aumentar drive en elementos agresivos - 'additional_heat_send': 0.05, # Un poco mas de heat - 'limiter_gain_factor': 1.15, # +15% gain para industrial techno - }, - 'latin': { - 'additional_pan_width': 0.05, - }, - 'peak-time': { - 'master_compressor_ratio_factor': 1.1, - 'limiter_gain_factor': 1.1, # +10% gain para peak-time - }, - 'minimal': { - 'fx_bus_send_reduction': 0.05, - 'additional_space_send': 0.03, # Un poco mas de reverb para espacio - }, -} - -ROLE_BUS_ASSIGNMENTS = { - 'sc_trigger': 'sc_trigger', # Rutea a su propio bus fantasma - 'kick': 'drums', - 'clap': 'drums', - 'snare_fill': 'drums', - 'hat_closed': 'drums', - 'hat_open': 'drums', - 'top_loop': 'drums', - 'perc': 'drums', - 'tom_fill': 'drums', - 'ride': 'drums', - 'crash': 'drums', - 'sub_bass': 'bass', - 'bass': 'bass', - 'drone': 'music', - 'chords': 'music', - 'stab': 'music', - 'pad': 'music', - 'pluck': 'music', - 'arp': 'music', - 'lead': 'music', - 'counter': 'music', - 'reverse_fx': 'fx', - 'riser': 'fx', - 'impact': 'fx', - 'atmos': 'fx', - 'vocal': 'vocal', -} - -SECTION_BLUEPRINTS = { - 'minimal': [ - ('INTRO', 8, 12, 'intro', 1), - ('GROOVE', 16, 20, 'build', 2), - ('BREAK', 8, 25, 'break', 1), - ('OUTRO', 8, 8, 'outro', 1), - ], - 'standard': [ - ('INTRO', 8, 12, 'intro', 1), - ('BUILD', 8, 18, 'build', 2), - ('DROP A', 16, 28, 'drop', 4), - ('BREAK', 8, 25, 'break', 1), - ('DROP B', 16, 30, 'drop', 5), - ('OUTRO', 8, 8, 'outro', 1), - ], - 'extended': [ - ('INTRO DJ', 16, 10, 'intro', 1), - ('BUILD A', 8, 18, 'build', 2), - ('DROP A', 16, 28, 'drop', 4), - ('BREAKDOWN', 8, 25, 'break', 1), - ('BUILD B', 8, 18, 'build', 3), - ('DROP B', 16, 30, 'drop', 5), - ('OUTRO DJ', 16, 8, 'outro', 1), - ], - 'club': [ - ('INTRO DJ', 16, 10, 'intro', 1), - ('GROOVE A', 16, 14, 'build', 2), - ('VOCAL BUILD', 8, 18, 'build', 3), - ('DROP A', 16, 28, 'drop', 4), - ('BREAKDOWN', 8, 25, 'break', 1), - ('BUILD B', 8, 18, 'build', 3), - ('DROP B', 16, 30, 'drop', 5), - ('PEAK', 8, 32, 'drop', 5), - ('OUTRO DJ', 16, 8, 'outro', 1), - ], - 'tech-house-dj': [ - ('INTRO DJ', 32, 8, 'intro', 1), - ('GROOVE A', 16, 16, 'build', 2), - ('VOX TEASE', 8, 20, 'build', 3), - ('DROP A', 32, 30, 'drop', 5), - ('BREAK', 8, 22, 'break', 1), - ('BUILD', 8, 24, 'build', 3), - ('DROP B', 32, 32, 'drop', 5), - ('OUTRO DJ', 32, 8, 'outro', 1), - ], -} - -SECTION_BLUEPRINT_VARIANTS = { - 'standard': [ - SECTION_BLUEPRINTS['standard'], - [ - ('INTRO', 8, 12, 'intro', 1), - ('GROOVE A', 8, 16, 'build', 2), - ('DROP A', 16, 28, 'drop', 4), - ('BREAKDOWN', 8, 24, 'break', 1), - ('BUILD B', 8, 20, 'build', 3), - ('DROP B', 16, 31, 'drop', 5), - ], - [ - ('INTRO DJ', 16, 10, 'intro', 1), - ('BUILD', 8, 18, 'build', 2), - ('DROP A', 16, 28, 'drop', 4), - ('MID BREAK', 8, 22, 'break', 1), - ('PEAK', 16, 31, 'drop', 5), - ], - ], - 'club': [ - SECTION_BLUEPRINTS['club'], - [ - ('INTRO DJ', 16, 10, 'intro', 1), - ('TEASE', 8, 14, 'build', 2), - ('GROOVE A', 16, 18, 'build', 3), - ('DROP A', 16, 28, 'drop', 4), - ('BREAKDOWN', 8, 24, 'break', 1), - ('BUILD B', 8, 20, 'build', 3), - ('PEAK', 16, 32, 'drop', 5), - ('OUTRO DJ', 24, 8, 'outro', 1), - ], - [ - ('INTRO DJ', 16, 10, 'intro', 1), - ('GROOVE A', 16, 15, 'build', 2), - ('VOCAL BUILD', 8, 20, 'build', 3), - ('DROP A', 16, 27, 'drop', 4), - ('MID BREAK', 8, 22, 'break', 1), - ('GROOVE B', 8, 18, 'build', 3), - ('DROP B', 24, 31, 'drop', 5), - ('OUTRO DJ', 16, 8, 'outro', 1), - ], - ], -} - -ROLE_ACTIVITY = { - 'sc_trigger': {'intro': 4, 'build': 4, 'drop': 4, 'break': 2, 'outro': 3}, - 'kick': {'intro': 2, 'build': 3, 'drop': 4, 'break': 1, 'outro': 2}, - 'clap': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, - 'snare_fill': {'intro': 0, 'build': 2, 'drop': 1, 'break': 1, 'outro': 0}, - 'hat_closed': {'intro': 1, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, - 'hat_open': {'intro': 0, 'build': 1, 'drop': 3, 'break': 0, 'outro': 1}, - 'top_loop': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, - 'perc': {'intro': 0, 'build': 2, 'drop': 3, 'break': 1, 'outro': 0}, - 'tom_fill': {'intro': 0, 'build': 1, 'drop': 1, 'break': 0, 'outro': 0}, - 'ride': {'intro': 0, 'build': 1, 'drop': 2, 'break': 0, 'outro': 1}, - 'crash': {'intro': 0, 'build': 1, 'drop': 1, 'break': 0, 'outro': 0}, - 'sub_bass': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, - 'bass': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 1}, - 'drone': {'intro': 2, 'build': 2, 'drop': 2, 'break': 3, 'outro': 2}, - 'chords': {'intro': 0, 'build': 2, 'drop': 3, 'break': 2, 'outro': 1}, - 'stab': {'intro': 0, 'build': 2, 'drop': 4, 'break': 1, 'outro': 0}, - 'pad': {'intro': 2, 'build': 2, 'drop': 2, 'break': 3, 'outro': 2}, - 'pluck': {'intro': 0, 'build': 2, 'drop': 3, 'break': 0, 'outro': 0}, - 'arp': {'intro': 0, 'build': 2, 'drop': 3, 'break': 1, 'outro': 0}, - 'lead': {'intro': 0, 'build': 1, 'drop': 4, 'break': 0, 'outro': 0}, - 'counter': {'intro': 0, 'build': 1, 'drop': 3, 'break': 1, 'outro': 0}, - 'reverse_fx': {'intro': 0, 'build': 2, 'drop': 1, 'break': 1, 'outro': 0}, - 'riser': {'intro': 0, 'build': 3, 'drop': 1, 'break': 2, 'outro': 0}, - 'impact': {'intro': 0, 'build': 2, 'drop': 1, 'break': 1, 'outro': 0}, - 'atmos': {'intro': 2, 'build': 1, 'drop': 1, 'break': 3, 'outro': 2}, - 'vocal': {'intro': 0, 'build': 1, 'drop': 2, 'break': 1, 'outro': 0}, -} - -# ROLE_MIX: Perfil de mezcla por rol -# Valores base que luego se calibran con ROLE_GAIN_CALIBRATION -# Volumenes calibrados relativos: kick = 0%, otros debajo -# Pan y sends optimizados para profundidad y espacio -ROLE_MIX = { - 'sc_trigger': {'volume': 0.0, 'pan': 0.0, 'sends': {'space': 0.0, 'echo': 0.0, 'heat': 0.0, 'glue': 0.0}}, - # DRUMS - Kick centered, elements below - 'kick': {'volume': 0.85, 'pan': 0.0, 'sends': {'space': 0.0, 'echo': 0.0, 'heat': 0.0, 'glue': 0.08}}, - 'clap': {'volume': 0.78, 'pan': 0.0, 'sends': {'space': 0.14, 'echo': 0.04, 'heat': 0.02, 'glue': 0.10}}, - 'snare_fill': {'volume': 0.72, 'pan': 0.0, 'sends': {'space': 0.12, 'echo': 0.10, 'heat': 0.01, 'glue': 0.06}}, - 'hat_closed': {'volume': 0.68, 'pan': -0.10, 'sends': {'space': 0.04, 'echo': 0.03, 'heat': 0.0, 'glue': 0.04}}, - 'hat_open': {'volume': 0.65, 'pan': 0.12, 'sends': {'space': 0.10, 'echo': 0.08, 'heat': 0.01, 'glue': 0.06}}, - 'top_loop': {'volume': 0.62, 'pan': -0.16, 'sends': {'space': 0.06, 'echo': 0.12, 'heat': 0.0, 'glue': 0.08}}, - 'perc': {'volume': 0.70, 'pan': 0.20, 'sends': {'space': 0.10, 'echo': 0.14, 'heat': 0.02, 'glue': 0.10}}, - 'tom_fill': {'volume': 0.68, 'pan': 0.12, 'sends': {'space': 0.12, 'echo': 0.10, 'heat': 0.01, 'glue': 0.06}}, - 'ride': {'volume': 0.58, 'pan': 0.24, 'sends': {'space': 0.04, 'echo': 0.03, 'heat': 0.0, 'glue': 0.06}}, - 'crash': {'volume': 0.50, 'pan': 0.0, 'sends': {'space': 0.18, 'echo': 0.06, 'heat': 0.01, 'glue': 0.02}}, - # BASS - Below drums, centered for mono compatibility - 'sub_bass': {'volume': 0.80, 'pan': 0.0, 'sends': {'space': 0.0, 'echo': 0.0, 'heat': 0.0, 'glue': 0.14}}, - 'bass': {'volume': 0.78, 'pan': 0.0, 'sends': {'space': 0.01, 'echo': 0.01, 'heat': 0.04, 'glue': 0.12}}, - # MUSIC - Layers below rhythm section - 'drone': {'volume': 0.55, 'pan': 0.0, 'sends': {'space': 0.28, 'echo': 0.08, 'heat': 0.02, 'glue': 0.04}}, - 'chords': {'volume': 0.70, 'pan': -0.06, 'sends': {'space': 0.18, 'echo': 0.12, 'heat': 0.01, 'glue': 0.08}}, - 'stab': {'volume': 0.65, 'pan': 0.10, 'sends': {'space': 0.12, 'echo': 0.10, 'heat': 0.04, 'glue': 0.08}}, - 'pad': {'volume': 0.60, 'pan': -0.14, 'sends': {'space': 0.32, 'echo': 0.08, 'heat': 0.0, 'glue': 0.06}}, - 'pluck': {'volume': 0.68, 'pan': 0.14, 'sends': {'space': 0.08, 'echo': 0.18, 'heat': 0.01, 'glue': 0.06}}, - 'arp': {'volume': 0.65, 'pan': -0.18, 'sends': {'space': 0.14, 'echo': 0.24, 'heat': 0.01, 'glue': 0.08}}, - 'lead': {'volume': 0.72, 'pan': 0.06, 'sends': {'space': 0.14, 'echo': 0.18, 'heat': 0.03, 'glue': 0.10}}, - 'counter': {'volume': 0.62, 'pan': 0.20, 'sends': {'space': 0.18, 'echo': 0.14, 'heat': 0.01, 'glue': 0.06}}, - # FX - Deep in the mix - 'reverse_fx': {'volume': 0.52, 'pan': 0.0, 'sends': {'space': 0.24, 'echo': 0.10, 'heat': 0.03, 'glue': 0.02}}, - 'riser': {'volume': 0.60, 'pan': 0.0, 'sends': {'space': 0.28, 'echo': 0.14, 'heat': 0.04, 'glue': 0.03}}, - 'impact': {'volume': 0.55, 'pan': 0.0, 'sends': {'space': 0.22, 'echo': 0.12, 'heat': 0.01, 'glue': 0.03}}, - 'atmos': {'volume': 0.50, 'pan': -0.20, 'sends': {'space': 0.34, 'echo': 0.06, 'heat': 0.0, 'glue': 0.03}}, - # VOCAL - Present but under drums - 'vocal': {'volume': 0.70, 'pan': 0.08, 'sends': {'space': 0.20, 'echo': 0.24, 'heat': 0.02, 'glue': 0.10}}, -} - -ARRANGEMENT_PROFILES = ( - { - 'name': 'warehouse', - 'genres': {'techno', 'tech-house'}, - 'drum_tightness': 1.15, - 'bass_motion': 'locked', - 'melodic_motion': 'restrained', - 'pan_width': 0.12, - 'fx_bias': 1.0, - }, - { - 'name': 'jackin', - 'genres': {'house', 'tech-house'}, - 'drum_tightness': 0.96, - 'bass_motion': 'bouncy', - 'melodic_motion': 'call_response', - 'pan_width': 0.16, - 'fx_bias': 0.92, - }, - { - 'name': 'festival', - 'genres': {'trance', 'house', 'tech-house'}, - 'drum_tightness': 0.92, - 'bass_motion': 'lifted', - 'melodic_motion': 'anthemic', - 'pan_width': 0.2, - 'fx_bias': 1.18, - }, - { - 'name': 'swing', - 'genres': {'tech-house', 'house'}, - 'drum_tightness': 0.9, - 'bass_motion': 'syncopated', - 'melodic_motion': 'hooky', - 'pan_width': 0.22, - 'fx_bias': 1.05, - }, - { - 'name': 'tech-house-club', - 'genres': {'tech-house'}, - 'drum_tightness': 0.94, - 'bass_motion': 'bouncy', - 'melodic_motion': 'hooky', - 'pan_width': 0.18, - 'fx_bias': 1.08, - 'bus_names': { - 'drums': 'DRUM CLUB', - 'bass': 'BASS TUBE', - 'music': 'MUSIC JACK', - 'vocal': 'VOCAL LATIN BUS', - 'fx': 'FX JAM', - }, - 'return_names': { - 'space': 'REVERB SHORT', - 'echo': 'DELAY MONO', - 'heat': 'DRIVE HOT', - 'glue': 'GLUE BUS', - }, - }, - { - 'name': 'tech-house-deep', - 'genres': {'tech-house'}, - 'drum_tightness': 1.02, - 'bass_motion': 'locked', - 'melodic_motion': 'restrained', - 'pan_width': 0.14, - 'fx_bias': 0.88, - 'bus_names': { - 'drums': 'DRUM DEEP', - 'bass': 'SUB DEEP', - 'music': 'ATMOS DEEP', - 'vocal': 'VOX DEEP', - 'fx': 'FX DEEP', - }, - 'return_names': { - 'space': 'REVERB DEEP', - 'echo': 'DELAY DEEP', - 'heat': 'SATURATE DEEP', - 'glue': 'GLUE MINIMAL', - }, - }, - { - 'name': 'tech-house-funky', - 'genres': {'tech-house'}, - 'drum_tightness': 0.86, - 'bass_motion': 'syncopated', - 'melodic_motion': 'hooky', - 'pan_width': 0.24, - 'fx_bias': 1.12, - 'bus_names': { - 'drums': 'DRUM GROOVE', - 'bass': 'BASS FUNK', - 'music': 'MUSIC GROOVE', - 'vocal': 'VOCAL FUNK', - 'fx': 'FX SWING', - }, - 'return_names': { - 'space': 'REVERB GROOVE', - 'echo': 'DELAY GROOVE', - 'heat': 'DRIVE FUNK', - 'glue': 'GLUE SWING', - }, - }, -) - -ROLE_FX_CHAINS = { - 'sc_trigger': [ - {'device': 'Utility', 'parameters': {'Gain': 0.0, 'Width': 0.0}}, - ], - 'kick': [ - {'device': 'Saturator', 'parameters': {'Drive': 2.5}}, - ], - 'clap': [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.08}}, - ], - 'snare_fill': [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 0.08}}, - ], - 'hat_closed': [ - {'device': 'Auto Filter', 'parameters': {'Frequency': 15000.0, 'Dry/Wet': 0.14}}, - ], - 'hat_open': [ - {'device': 'Auto Filter', 'parameters': {'Frequency': 12000.0, 'Dry/Wet': 0.18}}, - ], - 'top_loop': [ - {'device': 'Auto Filter', 'parameters': {'Frequency': 11000.0, 'Dry/Wet': 0.22}}, - ], - 'perc': [ - {'device': 'Auto Filter', 'parameters': {'Frequency': 9500.0, 'Dry/Wet': 0.16}}, - ], - 'ride': [ - {'device': 'Auto Filter', 'parameters': {'Frequency': 12500.0, 'Dry/Wet': 0.12}}, - ], - 'sub_bass': [ - {'device': 'Utility', 'parameters': {'Width': 0.0}}, - ], - 'bass': [ - {'device': 'Saturator', 'parameters': {'Drive': 4.0}}, - {'device': 'Auto Filter', 'parameters': {'Frequency': 7800.0, 'Dry/Wet': 0.12}}, - ], - 'drone': [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.16}}, - ], - 'chords': [ - {'device': 'Auto Filter', 'parameters': {'Frequency': 9800.0, 'Dry/Wet': 0.14}}, - ], - 'stab': [ - {'device': 'Saturator', 'parameters': {'Drive': 3.0}}, - ], - 'pad': [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.18}}, - ], - 'pluck': [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 0.12}}, - ], - 'arp': [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 0.16}}, - ], - 'lead': [ - {'device': 'Saturator', 'parameters': {'Drive': 2.0}}, - {'device': 'Echo', 'parameters': {'Dry/Wet': 0.12}}, - ], - 'counter': [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 0.1}}, - ], - 'crash': [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.16}}, - ], - 'reverse_fx': [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.24}}, - ], - 'riser': [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.28}}, - ], - 'impact': [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.12}}, - ], - 'atmos': [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.3}}, - ], - 'vocal': [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 0.14}}, - ], -} - -SCRIPTS_ROOT = Path(__file__).resolve().parents[2] -REFERENCE_SEARCH_DIRS = ( - SCRIPTS_ROOT / 'sample', - SCRIPTS_ROOT / 'samples', -) -REFERENCE_TRACK_PROFILES = [ - { - 'name': 'Eli Brown x GeezLy - Me Gusta', - 'match_terms': ['eli brown', 'geezly', 'me gusta'], - 'genre': 'tech-house', - 'style': 'latin-industrial', - 'bpm': 136.0, - 'key': 'F#m', - 'structure': 'club', - 'reference_bars': 112, - }, - { - 'name': 'Mr. Pauer, Goyo - Química', - 'match_terms': ['mr. pauer', 'goyo', 'química'], - 'genre': 'house', - 'style': 'latin-funky vocal', - 'bpm': 123.0, - 'key': 'Cm', - 'structure': 'extended', - 'reference_bars': 72, - }, -] - -# ========================================================================= -# SECTION AUTOMATION PARAMETERS -# ========================================================================= - -SECTION_AUTOMATION = { - 'intro': { - 'energy': 0.25, - 'filters': { - 'drums': {'frequency': 8500.0, 'resonance': 0.3, 'dry_wet': 0.12}, - 'bass': {'frequency': 6200.0, 'resonance': 0.25, 'dry_wet': 0.08}, - 'music': {'frequency': 7800.0, 'resonance': 0.2, 'dry_wet': 0.1}, - 'vocal': {'frequency': 9200.0, 'resonance': 0.15, 'dry_wet': 0.06}, - 'fx': {'frequency': 8800.0, 'resonance': 0.18, 'dry_wet': 0.14}, - }, - 'reverb': {'send_level': 0.28, 'decay_time': 2.8, 'size': 0.85}, - 'delay': {'send_level': 0.18, 'feedback': 0.35, 'time_l': 0.375, 'time_r': 0.5}, - 'compression': {'threshold': -14.0, 'ratio': 2.0, 'attack': 0.015, 'release': 0.12}, - 'saturation': {'drive': 0.8, 'mix': 0.15}, - 'stereo_width': {'value': 0.92}, - 'envelope_curve': 'ease_in', - }, - 'build': { - 'energy': 0.72, - 'filters': { - 'drums': {'frequency': 4200.0, 'resonance': 0.45, 'dry_wet': 0.22}, - 'bass': {'frequency': 3800.0, 'resonance': 0.35, 'dry_wet': 0.16}, - 'music': {'frequency': 5400.0, 'resonance': 0.28, 'dry_wet': 0.18}, - 'vocal': {'frequency': 6800.0, 'resonance': 0.22, 'dry_wet': 0.12}, - 'fx': {'frequency': 5200.0, 'resonance': 0.32, 'dry_wet': 0.24}, - }, - 'reverb': {'send_level': 0.18, 'decay_time': 2.2, 'size': 0.72}, - 'delay': {'send_level': 0.32, 'feedback': 0.48, 'time_l': 0.375, 'time_r': 0.5}, - 'compression': {'threshold': -10.0, 'ratio': 3.5, 'attack': 0.008, 'release': 0.08}, - 'saturation': {'drive': 2.2, 'mix': 0.28}, - 'stereo_width': {'value': 1.08}, - 'envelope_curve': 'ramp_up', - }, - 'drop': { - 'energy': 1.0, - 'filters': { - 'drums': {'frequency': 14500.0, 'resonance': 0.2, 'dry_wet': 0.04}, - 'bass': {'frequency': 9800.0, 'resonance': 0.15, 'dry_wet': 0.03}, - 'music': {'frequency': 12200.0, 'resonance': 0.12, 'dry_wet': 0.05}, - 'vocal': {'frequency': 12800.0, 'resonance': 0.1, 'dry_wet': 0.04}, - 'fx': {'frequency': 11000.0, 'resonance': 0.15, 'dry_wet': 0.08}, - }, - 'reverb': {'send_level': 0.12, 'decay_time': 1.6, 'size': 0.55}, - 'delay': {'send_level': 0.14, 'feedback': 0.28, 'time_l': 0.25, 'time_r': 0.375}, - 'compression': {'threshold': -6.0, 'ratio': 4.5, 'attack': 0.005, 'release': 0.06}, - 'saturation': {'drive': 3.5, 'mix': 0.38}, - 'stereo_width': {'value': 1.18}, - 'envelope_curve': 'punch', - }, - 'break': { - 'energy': 0.38, - 'filters': { - 'drums': {'frequency': 5200.0, 'resonance': 0.55, 'dry_wet': 0.32}, - 'bass': {'frequency': 2800.0, 'resonance': 0.45, 'dry_wet': 0.24}, - 'music': {'frequency': 6400.0, 'resonance': 0.35, 'dry_wet': 0.22}, - 'vocal': {'frequency': 8200.0, 'resonance': 0.28, 'dry_wet': 0.16}, - 'fx': {'frequency': 6800.0, 'resonance': 0.38, 'dry_wet': 0.28}, - }, - 'reverb': {'send_level': 0.42, 'decay_time': 3.5, 'size': 1.0}, - 'delay': {'send_level': 0.38, 'feedback': 0.52, 'time_l': 0.5, 'time_r': 0.75}, - 'compression': {'threshold': -18.0, 'ratio': 1.8, 'attack': 0.025, 'release': 0.18}, - 'saturation': {'drive': 0.5, 'mix': 0.1}, - 'stereo_width': {'value': 1.25}, - 'envelope_curve': 'ease_out', - }, - 'outro': { - 'energy': 0.32, - 'filters': { - 'drums': {'frequency': 6200.0, 'resonance': 0.35, 'dry_wet': 0.18}, - 'bass': {'frequency': 4200.0, 'resonance': 0.28, 'dry_wet': 0.14}, - 'music': {'frequency': 5600.0, 'resonance': 0.25, 'dry_wet': 0.16}, - 'vocal': {'frequency': 7200.0, 'resonance': 0.2, 'dry_wet': 0.1}, - 'fx': {'frequency': 6400.0, 'resonance': 0.28, 'dry_wet': 0.2}, - }, - 'reverb': {'send_level': 0.35, 'decay_time': 3.2, 'size': 0.92}, - 'delay': {'send_level': 0.28, 'feedback': 0.42, 'time_l': 0.375, 'time_r': 0.5}, - 'compression': {'threshold': -12.0, 'ratio': 2.2, 'attack': 0.018, 'release': 0.15}, - 'saturation': {'drive': 0.6, 'mix': 0.12}, - 'stereo_width': {'value': 0.98}, - 'envelope_curve': 'ease_out', - }, -} - -# Envelope curve templates for automation interpolation -ENVELOPE_CURVES = { - 'linear': lambda x: x, - 'ease_in': lambda x: x * x, - 'ease_out': lambda x: 1 - (1 - x) ** 2, - 'ease_in_out': lambda x: 3 * x * x - 2 * x * x * x, - 'ramp_up': lambda x: x ** 0.5, - 'ramp_down': lambda x: 1 - (1 - x) ** 2, - 'punch': lambda x: min(1.0, x * 2.0) if x < 0.5 else 1.0 - (1.0 - x) ** 0.5, - 's_curve': lambda x: 1 / (1 + (2.71828 ** (-10 * (x - 0.5)))), - 'exponential': lambda x: (2.71828 ** (x - 1) - 0.3679) / 0.6321, -} - -# ============================================================================= -# AUTOMATIZACION DE DEVICES POR SECCION - FASE 2 -# Parametros especificos por device para cada tipo de seccion -# ============================================================================= - -# Automatizacion de devices en tracks individuales por rol - ENHANCED -SECTION_DEVICE_AUTOMATION = { - # BASS - Filtros, drive y compresion dinamica - 'bass': { - 'Saturator': { - 'Drive': {'intro': 1.5, 'build': 3.5, 'drop': 5.0, 'break': 2.0, 'outro': 1.8}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.30, 'break': 0.15, 'outro': 0.10}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6200.0, 'build': 8500.0, 'drop': 12000.0, 'break': 4800.0, 'outro': 5800.0}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.06}, - 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, - }, - 'Compressor': { - 'Threshold': {'intro': -12.0, 'build': -14.0, 'drop': -18.0, 'break': -10.0, 'outro': -11.0}, - 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.0, 'outro': 2.2}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, - }, - }, - 'sub_bass': { - 'Saturator': { - 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 5200.0, 'build': 7200.0, 'drop': 10000.0, 'break': 4200.0, 'outro': 4800.0}, - 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.06, 'break': 0.14, 'outro': 0.04}, - }, - 'Utility': { - 'Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, - 'Gain': {'intro': 0.0, 'build': 0.2, 'drop': 0.4, 'break': -0.2, 'outro': 0.0}, - }, - }, - # PAD - Filtros envolventes con width y reverb - 'pad': { - 'Auto Filter': { - 'Frequency': {'intro': 4500.0, 'build': 8000.0, 'drop': 11000.0, 'break': 3200.0, 'outro': 4000.0}, - 'Dry/Wet': {'intro': 0.25, 'build': 0.18, 'drop': 0.12, 'break': 0.35, 'outro': 0.28}, - 'Resonance': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.22, 'build': 0.16, 'drop': 0.10, 'break': 0.28, 'outro': 0.24}, - 'Decay Time': {'intro': 3.5, 'build': 2.8, 'drop': 2.0, 'break': 4.2, 'outro': 3.8}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.85, 'build': 1.02, 'drop': 1.12, 'break': 1.25, 'outro': 0.90}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 0.6, 'outro': 0.7}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.20, 'break': 0.08, 'outro': 0.12}, - }, - }, - # ATMOS - Filtros espaciales con movement - 'atmos': { - 'Auto Filter': { - 'Frequency': {'intro': 3800.0, 'build': 7200.0, 'drop': 9800.0, 'break': 2800.0, 'outro': 3500.0}, - 'Dry/Wet': {'intro': 0.30, 'build': 0.22, 'drop': 0.15, 'break': 0.40, 'outro': 0.32}, - 'Resonance': {'intro': 0.22, 'build': 0.32, 'drop': 0.18, 'break': 0.42, 'outro': 0.25}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.35, 'build': 0.28, 'drop': 0.18, 'break': 0.42, 'outro': 0.38}, - 'Decay Time': {'intro': 4.0, 'build': 3.2, 'drop': 2.2, 'break': 5.0, 'outro': 4.5}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.70, 'build': 0.88, 'drop': 1.05, 'break': 1.20, 'outro': 0.75}, - }, - }, - # FX ELEMENTS - 'reverse_fx': { - 'Auto Filter': { - 'Frequency': {'intro': 5200.0, 'build': 9000.0, 'drop': 12000.0, 'break': 6000.0, 'outro': 4800.0}, - 'Dry/Wet': {'intro': 0.20, 'build': 0.28, 'drop': 0.15, 'break': 0.35, 'outro': 0.22}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.30, 'build': 0.35, 'drop': 0.20, 'break': 0.40, 'outro': 0.28}, - 'Decay Time': {'intro': 3.0, 'build': 4.5, 'drop': 2.5, 'break': 5.5, 'outro': 3.5}, - }, - 'Saturator': { - 'Drive': {'intro': 1.2, 'build': 2.8, 'drop': 4.5, 'break': 1.8, 'outro': 1.0}, - }, - }, - 'riser': { - 'Auto Filter': { - 'Frequency': {'intro': 4000.0, 'build': 10000.0, 'drop': 14000.0, 'break': 5500.0, 'outro': 4200.0}, - 'Dry/Wet': {'intro': 0.15, 'build': 0.30, 'drop': 0.12, 'break': 0.22, 'outro': 0.18}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.25, 'build': 0.40, 'drop': 0.22, 'break': 0.35, 'outro': 0.20}, - 'Decay Time': {'intro': 2.5, 'build': 5.0, 'drop': 3.0, 'break': 4.0, 'outro': 2.8}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.18, 'build': 0.35, 'drop': 0.15, 'break': 0.25, 'outro': 0.15}, - 'Feedback': {'intro': 0.30, 'build': 0.55, 'drop': 0.25, 'break': 0.45, 'outro': 0.28}, - }, - 'Saturator': { - 'Drive': {'intro': 1.5, 'build': 4.0, 'drop': 3.0, 'break': 2.5, 'outro': 1.2}, - }, - }, - 'impact': { - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.15, 'build': 0.18, 'drop': 0.12, 'break': 0.20, 'outro': 0.14}, - 'Decay Time': {'intro': 2.0, 'build': 2.5, 'drop': 1.8, 'break': 3.0, 'outro': 2.2}, - }, - 'Saturator': { - 'Drive': {'intro': 1.8, 'build': 2.5, 'drop': 3.5, 'break': 2.0, 'outro': 1.5}, - }, - }, - 'drone': { - 'Auto Filter': { - 'Frequency': {'intro': 3000.0, 'build': 6500.0, 'drop': 9000.0, 'break': 2500.0, 'outro': 2800.0}, - 'Dry/Wet': {'intro': 0.20, 'build': 0.15, 'drop': 0.10, 'break': 0.30, 'outro': 0.22}, - 'Resonance': {'intro': 0.25, 'build': 0.35, 'drop': 0.22, 'break': 0.40, 'outro': 0.28}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.18, 'build': 0.14, 'drop': 0.08, 'break': 0.25, 'outro': 0.20}, - 'Decay Time': {'intro': 4.5, 'build': 3.5, 'drop': 2.5, 'break': 5.5, 'outro': 4.8}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 0.6, 'outro': 0.7}, - }, - }, - # HATS - Filtros de brillantez con resonance y saturacion - 'hat_closed': { - 'Auto Filter': { - 'Frequency': {'intro': 12000.0, 'build': 14000.0, 'drop': 16000.0, 'break': 10000.0, 'outro': 11000.0}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.14}, - 'Resonance': {'intro': 0.15, 'build': 0.25, 'drop': 0.12, 'outro': 0.18, 'break': 0.30}, - }, - 'Saturator': { - 'Drive': {'intro': 0.5, 'build': 1.2, 'drop': 1.8, 'break': 0.8, 'outro': 0.6}, - }, - }, - 'hat_open': { - 'Auto Filter': { - 'Frequency': {'intro': 9000.0, 'build': 11000.0, 'drop': 13000.0, 'break': 7500.0, 'outro': 8500.0}, - 'Dry/Wet': {'intro': 0.18, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.20}, - 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.22, 'outro': 0.12}, - }, - }, - 'top_loop': { - 'Auto Filter': { - 'Frequency': {'intro': 8500.0, 'build': 10500.0, 'drop': 12500.0, 'break': 7000.0, 'outro': 8000.0}, - 'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.16, 'break': 0.32, 'outro': 0.22}, - 'Resonance': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'outro': 0.15, 'break': 0.28}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.05, 'build': 0.12, 'drop': 0.08, 'break': 0.18, 'outro': 0.10}, - }, - }, - # SYNTHS - 'chords': { - 'Auto Filter': { - 'Frequency': {'intro': 5500.0, 'build': 8500.0, 'drop': 11000.0, 'break': 4000.0, 'outro': 5000.0}, - 'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.12, 'break': 0.28, 'outro': 0.18}, - 'Resonance': {'intro': 0.18, 'build': 0.28, 'drop': 0.15, 'outro': 0.20, 'break': 0.35}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, - 'Feedback': {'intro': 0.25, 'build': 0.40, 'drop': 0.30, 'break': 0.45, 'outro': 0.28}, - }, - 'Saturator': { - 'Drive': {'intro': 1.2, 'build': 2.2, 'drop': 3.5, 'break': 1.5, 'outro': 1.0}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.95, 'build': 1.05, 'drop': 1.15, 'break': 1.25, 'outro': 1.00}, - }, - }, - 'lead': { - 'Saturator': { - 'Drive': {'intro': 1.0, 'build': 2.5, 'drop': 4.0, 'break': 1.5, 'outro': 1.2}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.20, 'drop': 0.25, 'break': 0.10, 'outro': 0.15}, - }, - 'Echo': { - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.18, 'outro': 0.10}, - 'Feedback': {'intro': 0.20, 'build': 0.35, 'drop': 0.28, 'break': 0.40, 'outro': 0.22}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 4500.0, 'outro': 5500.0}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.90, 'build': 1.02, 'drop': 1.10, 'break': 1.18, 'outro': 0.95}, - }, - }, - 'stab': { - 'Saturator': { - 'Drive': {'intro': 2.0, 'build': 3.5, 'drop': 5.0, 'break': 2.5, 'outro': 2.2}, - 'Dry/Wet': {'intro': 0.18, 'build': 0.25, 'drop': 0.30, 'break': 0.15, 'outro': 0.20}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 12000.0, 'break': 5000.0, 'outro': 5500.0}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.15, 'drop': 0.08, 'break': 0.22, 'outro': 0.12}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.88, 'build': 1.00, 'drop': 1.12, 'break': 1.20, 'outro': 0.92}, - }, - }, - 'pluck': { - 'Echo': { - 'Dry/Wet': {'intro': 0.12, 'build': 0.22, 'drop': 0.14, 'break': 0.28, 'outro': 0.15}, - 'Feedback': {'intro': 0.30, 'build': 0.45, 'drop': 0.35, 'break': 0.50, 'outro': 0.32}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 7000.0, 'build': 10000.0, 'drop': 13000.0, 'break': 5500.0, 'outro': 6500.0}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.8, 'break': 1.2, 'outro': 0.9}, - }, - }, - 'arp': { - 'Echo': { - 'Dry/Wet': {'intro': 0.15, 'build': 0.28, 'drop': 0.18, 'break': 0.35, 'outro': 0.18}, - 'Feedback': {'intro': 0.35, 'build': 0.50, 'drop': 0.40, 'break': 0.58, 'outro': 0.38}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6500.0, 'build': 9500.0, 'drop': 12500.0, 'break': 5000.0, 'outro': 6000.0}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.14, 'break': 0.25, 'outro': 0.15}, - }, - 'Saturator': { - 'Drive': {'intro': 0.6, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.7}, - }, - }, - 'counter': { - 'Echo': { - 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.12, 'break': 0.22, 'outro': 0.12}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6000.0, 'build': 8800.0, 'drop': 11500.0, 'break': 4800.0, 'outro': 5200.0}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.12, 'break': 0.22, 'outro': 0.14}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.75, 'build': 0.92, 'drop': 1.08, 'break': 1.15, 'outro': 0.80}, - }, - }, - # VOCAL - 'vocal': { - 'Echo': { - 'Dry/Wet': {'intro': 0.12, 'build': 0.25, 'drop': 0.15, 'break': 0.30, 'outro': 0.14}, - 'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.30, 'break': 0.48, 'outro': 0.28}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.06, 'break': 0.18, 'outro': 0.10}, - 'Decay Time': {'intro': 2.5, 'build': 3.5, 'drop': 2.0, 'break': 4.0, 'outro': 2.8}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 6000.0, 'build': 9000.0, 'drop': 11000.0, 'break': 5000.0, 'outro': 5500.0}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.16, 'drop': 0.10, 'break': 0.20, 'outro': 0.12}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.8, 'drop': 2.5, 'break': 1.2, 'outro': 0.9}, - }, - }, - # DRUMS - Sin automatizacion de devices (manejados por volumen/sends) - 'kick': {}, - 'clap': {}, - 'snare_fill': {}, - 'perc': {}, - 'ride': {}, - 'tom_fill': {}, - 'crash': {}, - 'sc_trigger': {}, -} - -# Automatizacion de devices en BUSES por seccion - ENHANCED -BUS_DEVICE_AUTOMATION = { - 'drums': { - 'Compressor': { - 'Threshold': {'intro': -14.0, 'build': -16.0, 'drop': -18.5, 'break': -12.0, 'outro': -13.5}, - 'Ratio': {'intro': 2.5, 'build': 3.0, 'drop': 4.0, 'break': 2.2, 'outro': 2.4}, - 'Attack': {'intro': 0.015, 'build': 0.010, 'drop': 0.005, 'break': 0.020, 'outro': 0.018}, - }, - 'Saturator': { - 'Drive': {'intro': 0.8, 'build': 1.5, 'drop': 2.5, 'break': 1.0, 'outro': 0.9}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.10, 'outro': 0.10}, - }, - 'Limiter': { - 'Gain': {'intro': 0.2, 'build': 0.3, 'drop': 0.5, 'break': 0.15, 'outro': 0.18}, - }, - 'AutoFilter': { - 'Frequency': {'intro': 8500.0, 'build': 12500.0, 'drop': 16000.0, 'break': 4500.0, 'outro': 6500.0}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.22, 'drop': 0.04, 'break': 0.35, 'outro': 0.18}, - 'Resonance': {'intro': 0.20, 'build': 0.12, 'drop': 0.08, 'break': 0.50, 'outro': 0.28}, - }, - }, - 'bass': { - 'Saturator': { - 'Drive': {'intro': 1.0, 'build': 2.0, 'drop': 3.5, 'break': 1.5, 'outro': 1.2}, - 'Dry/Wet': {'intro': 0.10, 'build': 0.18, 'drop': 0.25, 'break': 0.12, 'outro': 0.10}, - }, - 'Compressor': { - 'Threshold': {'intro': -15.0, 'build': -17.0, 'drop': -20.0, 'break': -14.0, 'outro': -14.5}, - 'Ratio': {'intro': 3.0, 'build': 3.5, 'drop': 4.5, 'break': 2.8, 'outro': 3.0}, - 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.008, 'break': 0.025, 'outro': 0.022}, - }, - 'Utility': { - 'Stereo Width': {'intro': 0.0, 'build': 0.0, 'drop': 0.0, 'break': 0.0, 'outro': 0.0}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 4800.0, 'build': 8500.0, 'drop': 12000.0, 'break': 3200.0, 'outro': 4200.0}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.05, 'break': 0.25, 'outro': 0.12}, - 'Resonance': {'intro': 0.18, 'build': 0.12, 'drop': 0.08, 'break': 0.45, 'outro': 0.22}, - }, - }, - 'music': { - 'Compressor': { - 'Threshold': {'intro': -19.0, 'build': -20.0, 'drop': -22.0, 'break': -18.0, 'outro': -18.5}, - 'Ratio': {'intro': 2.0, 'build': 2.5, 'drop': 3.0, 'break': 1.8, 'outro': 2.0}, - 'Attack': {'intro': 0.025, 'build': 0.020, 'drop': 0.015, 'break': 0.030, 'outro': 0.028}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 7500.0, 'build': 12000.0, 'drop': 16000.0, 'break': 4500.0, 'outro': 6000.0}, - 'Dry/Wet': {'intro': 0.12, 'build': 0.18, 'drop': 0.03, 'break': 0.30, 'outro': 0.15}, - 'Resonance': {'intro': 0.18, 'build': 0.10, 'drop': 0.06, 'break': 0.40, 'outro': 0.22}, - }, - 'Utility': { - 'Stereo Width': {'intro': 1.02, 'build': 1.08, 'drop': 1.12, 'break': 1.25, 'outro': 1.05}, - }, - 'Saturator': { - 'Drive': {'intro': 0.3, 'build': 0.8, 'drop': 1.5, 'break': 0.4, 'outro': 0.35}, - 'Dry/Wet': {'intro': 0.05, 'build': 0.10, 'drop': 0.15, 'break': 0.08, 'outro': 0.06}, - }, - }, - 'vocal': { - 'Echo': { - 'Dry/Wet': {'intro': 0.06, 'build': 0.12, 'drop': 0.05, 'break': 0.18, 'outro': 0.08}, - 'Feedback': {'intro': 0.25, 'build': 0.42, 'drop': 0.28, 'break': 0.50, 'outro': 0.30}, - }, - 'Compressor': { - 'Threshold': {'intro': -16.0, 'build': -17.0, 'drop': -19.0, 'break': -15.0, 'outro': -15.5}, - 'Ratio': {'intro': 2.8, 'build': 3.2, 'drop': 3.8, 'break': 2.5, 'outro': 2.7}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.06, 'build': 0.10, 'drop': 0.03, 'break': 0.16, 'outro': 0.08}, - 'Decay Time': {'intro': 2.2, 'build': 3.0, 'drop': 1.6, 'break': 4.0, 'outro': 2.5}, - }, - 'Auto Filter': { - 'Frequency': {'intro': 8000.0, 'build': 11500.0, 'drop': 14500.0, 'break': 6000.0, 'outro': 7200.0}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.12, 'drop': 0.04, 'break': 0.22, 'outro': 0.10}, - 'Resonance': {'intro': 0.15, 'build': 0.10, 'drop': 0.06, 'break': 0.32, 'outro': 0.18}, - }, - }, - 'fx': { - 'Auto Filter': { - 'Frequency': {'intro': 6000.0, 'build': 10500.0, 'drop': 14000.0, 'break': 4000.0, 'outro': 5200.0}, - 'Dry/Wet': {'intro': 0.15, 'build': 0.20, 'drop': 0.06, 'outro': 0.18, 'break': 0.35}, - 'Resonance': {'intro': 0.18, 'build': 0.15, 'drop': 0.10, 'break': 0.42, 'outro': 0.22}, - }, - 'Hybrid Reverb': { - 'Dry/Wet': {'intro': 0.20, 'build': 0.25, 'drop': 0.10, 'break': 0.38, 'outro': 0.22}, - 'Decay Time': {'intro': 3.0, 'build': 3.8, 'drop': 2.0, 'break': 5.0, 'outro': 3.5}, - }, - 'Limiter': { - 'Gain': {'intro': -0.3, 'build': 0.0, 'drop': 0.2, 'break': -0.5, 'outro': -0.2}, - }, - 'Saturator': { - 'Drive': {'intro': 0.5, 'build': 1.5, 'drop': 2.2, 'break': 0.8, 'outro': 0.6}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.14, 'drop': 0.20, 'break': 0.10, 'outro': 0.10}, - }, - }, -} - -# Automatizacion de devices en MASTER por seccion - ENHANCED -MASTER_DEVICE_AUTOMATION = { - 'Utility': {'Stereo Width': {'intro': 1.04, 'build': 1.08, 'drop': 1.10, 'break': 1.12, 'outro': 1.06}, - 'Gain': {'intro': 0.72, 'build': 0.88, 'drop': 1.0, 'break': 0.68, 'outro': 0.70}, - }, - 'Saturator': {'Drive': {'intro': 0.18, 'build': 0.30, 'drop': 0.45, 'break': 0.12, 'outro': 0.15}, - 'Dry/Wet': {'intro': 0.08, 'build': 0.15, 'drop': 0.22, 'break': 0.06, 'outro': 0.10}, - }, - 'Compressor': {'Ratio': {'intro': 0.55, 'build': 0.62, 'drop': 0.68, 'break': 0.50, 'outro': 0.52}, - 'Threshold': {'intro': -10.0, 'build': -12.0, 'drop': -13.5, 'break': -8.0, 'outro': -9.0}, - 'Attack': {'intro': 0.020, 'build': 0.015, 'drop': 0.010, 'break': 0.025, 'outro': 0.022}, - 'Release': {'intro': 0.15, 'build': 0.12, 'drop': 0.10, 'break': 0.18, 'outro': 0.16}, - }, - 'Limiter': {'Gain': {'intro': 1.05, 'build': 1.12, 'drop': 1.20, 'break': 1.00, 'outro': 1.02}, - 'Ceiling': {'intro': -0.5, 'build': -0.7, 'drop': -0.9, 'break': -0.4, 'outro': -0.45}, - }, - 'Auto Filter': {'Frequency': {'intro': 8500.0, 'build': 12000.0, 'drop': 16000.0, 'break': 5500.0, 'outro': 7500.0}, - 'Dry/Wet': {'intro': 0.04, 'build': 0.02, 'drop': 0.01, 'break': 0.06, 'outro': 0.05}, - }, - 'Echo': {'Dry/Wet': {'intro': 0.02, 'build': 0.05, 'drop': 0.03, 'break': 0.07, 'outro': 0.03}, - 'Feedback': {'intro': 0.15, 'build': 0.25, 'drop': 0.18, 'break': 0.30, 'outro': 0.20}, - }, -} - -DEVICE_PARAMETER_SAFETY_CLAMPS = { - 'Drive': {'min': 0.0, 'max': 6.0}, - 'Frequency': {'min': 20.0, 'max': 20000.0}, - 'Dry/Wet': {'min': 0.0, 'max': 1.0}, - 'Feedback': {'min': 0.0, 'max': 0.7}, - 'Stereo Width': {'min': 0.0, 'max': 1.3}, - 'Resonance': {'min': 0.0, 'max': 1.0}, - 'Ratio': {'min': 1.0, 'max': 20.0}, - 'Threshold': {'min': -60.0, 'max': 0.0}, - 'Attack': {'min': 0.0001, 'max': 0.5}, - 'Release': {'min': 0.001, 'max': 2.0}, - 'Gain': {'min': -1.0, 'max': 1.8}, - 'Decay Time': {'min': 0.1, 'max': 10.0}, -} - -MASTER_SAFETY_CLAMPS = { - 'Stereo Width': {'min': 0.0, 'max': 1.25}, - 'Drive': {'min': 0.0, 'max': 1.5}, - 'Ratio': {'min': 0.45, 'max': 0.9}, - 'Gain': {'min': 0.0, 'max': 1.6}, - 'Attack': {'min': 0.0001, 'max': 0.1}, - 'Ceiling': {'min': -3.0, 'max': 0.0}, - 'Threshold': {'min': -20.0, 'max': 0.0}, - 'Release': {'min': 0.001, 'max': 1.0}, -} - -# Expanded configuration de variación por sección -SECTION_VARIATION_CONFIG = { - 'perc': { - 'intro': {'sparse': True, 'intensity': 0.3, 'variant': 'ghost'}, - 'build': {'building': True, 'intensity': 0.8, 'variant': 'layering'}, - 'drop': {'full': True, 'intensity': 1.0, 'variant': 'layered'}, - 'break': {'sparse': True, 'intensity': 0.4, 'variant': 'minimal'}, - 'outro': {'fading': True, 'intensity': 0.3, 'variant': 'strip_down'}, - }, - 'perc_alt': { - 'intro': {'sparse': True, 'intensity': 0.2, 'variant': 'minimal'}, - 'build': {'building': True, 'intensity': 0.6, 'variant': 'tension'}, - 'drop': {'full': True, 'intensity': 0.7, 'variant': 'groove'}, - 'break': {'sparse': True, 'intensity': 0.3, 'variant': 'atmos'}, - 'outro': {'fading': True, 'intensity': 0.2, 'variant': 'minimal'}, - }, - 'top_loop': { - 'intro': {'use': False, 'variant': 'absent'}, - 'build': {'building': True, 'intensity': 0.8, 'variant': 'energy'}, - 'drop': {'full': True, 'intensity': 1.0, 'variant': 'full'}, - 'break': {'sparse': True, 'intensity': 0.4, 'variant': 'filtered'}, - 'outro': {'use': False, 'variant': 'absent'}, - }, - 'hat_open': { - 'intro': {'use': False, 'variant': 'absent'}, - 'build': {'building': True, 'intensity': 0.7, 'variant': 'tease'}, - 'drop': {'full': True, 'intensity': 0.9, 'variant': 'offbeat'}, - 'break': {'sparse': True, 'intensity': 0.3, 'variant': 'filtered'}, - 'outro': {'fading': True, 'intensity': 0.4, 'variant': 'fading'}, - }, - 'ride': { - 'intro': {'use': False, 'variant': 'absent'}, - 'build': {'building': True, 'intensity': 0.6, 'variant': 'building'}, - 'drop': {'full': True, 'intensity': 0.8, 'variant': 'full'}, - 'break': {'sparse': True, 'intensity': 0.3, 'variant': 'sparse'}, - 'outro': {'fading': True, 'intensity': 0.4, 'variant': 'minimal'}, - }, - 'snare_fill': { - 'intro': {'use': False, 'variant': 'absent'}, - 'build': {'tension': True, 'intensity': 0.8, 'variant': 'rolling'}, - 'drop': {'impact': True, 'intensity': 0.6, 'variant': 'fill'}, - 'break': {'sparse': True, 'intensity': 0.5, 'variant': 'tension'}, - 'outro': {'use': False, 'variant': 'absent'}, - }, - 'tom_fill': { - 'intro': {'use': False, 'variant': 'absent'}, - 'build': {'rising': True, 'intensity': 0.7, 'variant': 'rising'}, - 'drop': {'impact': True, 'intensity': 0.5, 'variant': 'fill'}, - 'break': {'use': False, 'variant': 'absent'}, - 'outro': {'use': False, 'variant': 'absent'}, - }, - 'vocal_shot': { - 'intro': {'sparse': True, 'variant': 'hint'}, - 'build': {'building': True, 'variant': 'anticipate'}, - 'drop': {'full': True, 'variant': 'hook'}, - 'break': {'sparse': True, 'variant': 'filtered'}, - 'outro': {'fading': True, 'variant': 'minimal'}, - }, - 'synth_peak': { - 'intro': {'use': False, 'variant': 'absent'}, - 'build': {'building': True, 'variant': 'rising'}, - 'drop': {'full': True, 'variant': 'anthem'}, - 'break': {'use': False, 'variant': 'absent'}, - 'outro': {'use': False, 'variant': 'absent'}, - }, - 'atmos': { - 'intro': {'full': True, 'decay': 'long', 'variant': 'atmospheric'}, - 'build': {'building': True, 'variant': 'tension'}, - 'drop': {'sparse': True, 'variant': 'minimal'}, - 'break': {'full': True, 'decay': 'long', 'variant': 'ethereal'}, - 'outro': {'fading': True, 'decay': 'long', 'variant': 'fading'}, - }, - 'chords': { - 'intro': {'sparse': True, 'variant': 'foreshadow'}, - 'build': {'building': True, 'variant': 'rising'}, - 'drop': {'full': True, 'variant': 'full'}, - 'break': {'sparse': True, 'variant': 'atmospheric'}, - 'outro': {'fading': True, 'variant': 'echo'}, - }, - 'pad': { - 'intro': {'full': True, 'variant': 'atmospheric'}, - 'build': {'building': True, 'variant': 'tension'}, - 'drop': {'sparse': True, 'variant': 'minimal'}, - 'break': {'full': True, 'variant': 'ethereal'}, - 'outro': {'fading': True, 'variant': 'decay'}, - }, - 'lead': { - 'intro': {'use': False, 'variant': 'absent'}, - 'build': {'building': True, 'variant': 'rising'}, - 'drop': {'full': True, 'variant': 'hook'}, - 'break': {'sparse': True, 'variant': 'minimal'}, - 'outro': {'use': False, 'variant': 'absent'}, - }, - 'arp': { - 'intro': {'sparse': True, 'variant': 'ghost'}, - 'build': {'building': True, 'variant': 'energy'}, - 'drop': {'full': True, 'variant': 'driving'}, - 'break': {'sparse': True, 'variant': 'filtered'}, - 'outro': {'use': False, 'variant': 'absent'}, - }, - 'pluck': { - 'intro': {'sparse': True, 'variant': 'hint'}, - 'build': {'building': True, 'variant': 'tension'}, - 'drop': {'full': True, 'variant': 'punchy'}, - 'break': {'sparse': True, 'variant': 'minimal'}, - 'outro': {'fading': True, 'variant': 'strip_down'}, - }, - 'bass': { - 'intro': {'sparse': True, 'variant': 'subtle'}, - 'build': {'building': True, 'variant': 'rising'}, - 'drop': {'full': True, 'variant': 'groove'}, - 'break': {'sparse': True, 'variant': 'filtered'}, - 'outro': {'fading': True, 'variant': 'fading'}, - }, - 'sub_bass': { - 'intro': {'use': False, 'variant': 'absent'}, - 'build': {'building': True, 'variant': 'hint'}, - 'drop': {'full': True, 'variant': 'deep'}, - 'break': {'sparse': True, 'variant': 'minimal'}, - 'outro': {'use': False, 'variant': 'absent'}, - }, - 'stab': { - 'intro': {'use': False, 'variant': 'absent'}, - 'build': {'sparse': True, 'variant': 'hint'}, - 'drop': {'full': True, 'variant': 'impact'}, - 'break': {'use': False, 'variant': 'absent'}, - 'outro': {'use': False, 'variant': 'absent'}, - }, -} - -# ============================================================================= -# DRUM PATTERN BANKS - Expanded variants for section-specific patterns -# ============================================================================= - -# Section-specific drum variants - EXPANDED for variation -DRUM_SECTION_VARIANTS = { - 'intro': { - 'kick': ['sparse', 'minimal', 'foreshadow', 'hint'], - 'clap': ['absent', 'hint'], - 'hat_closed': ['sparse', 'ghost', 'whisper'], - 'hat_open': ['absent', 'hint'], - 'perc': ['minimal', 'atmos', 'ghost'], - 'ride': ['absent'], - 'top_loop': ['absent', 'hint'], - 'snare_fill': ['absent'], - 'tom_fill': ['absent'], - }, - 'build': { - 'kick': ['building', 'pressure', 'rising', 'tension'], - 'clap': ['building', 'anticipate', 'roll_in'], - 'hat_closed': ['building', 'open_up', 'hyper'], - 'hat_open': ['building', 'tease'], - 'perc': ['layering', 'tension', 'build_up'], - 'ride': ['building', 'rising'], - 'top_loop': ['building', 'energy'], - 'snare_fill': ['rolling', 'tension'], - 'tom_fill': ['rising', 'fill'], - }, - 'drop': { - 'kick': ['full', 'punch', 'four_on_floor', 'groove', 'impact'], - 'clap': ['full', 'backbeat', 'syncopated', 'punch'], - 'hat_closed': ['full', 'groove', 'offbeat', 'shuffle'], - 'hat_open': ['full', 'offbeat', 'groove'], - 'perc': ['full', 'layered', 'groove', 'latin', 'tribal'], - 'ride': ['full', 'groove', 'energy'], - 'top_loop': ['full', 'energy', 'layered'], - 'snare_fill': ['drop_hit', 'fill'], - 'tom_fill': ['drop_hit', 'fill'], - }, - 'break': { - 'kick': ['sparse', 'absent', 'minimal', 'foreshadow'], - 'clap': ['sparse', 'offbeat', 'ghost'], - 'hat_closed': ['open', 'sparse', 'atmos', 'filtered'], - 'hat_open': ['sparse', 'filtered'], - 'perc': ['minimal', 'atmos', 'filtered'], - 'ride': ['sparse', 'filtered'], - 'top_loop': ['filtered', 'hint'], - 'snare_fill': ['tension'], - 'tom_fill': ['tension'], - }, - 'outro': { - 'kick': ['fading', 'minimal', 'sparse', 'strip_down'], - 'clap': ['fading', 'sparse', 'last_hit'], - 'hat_closed': ['fading', 'open', 'minimal'], - 'hat_open': ['fading', 'last_hit'], - 'perc': ['fading', 'minimal', 'strip_down'], - 'ride': ['fading', 'minimal'], - 'top_loop': ['fading', 'minimal'], - 'snare_fill': ['end_fill', 'absent'], - 'tom_fill': ['end_fill', 'absent'], - }, -} - -# Expanded drum pattern generators for section variation -DRUM_PATTERN_BANKS = { - 'kick': { - 'four_on_floor': [0.0, 1.0, 2.0, 3.0], - 'sparse': [0.0, 2.0], - 'minimal': [0.0], - 'foreshadow': [0.0, 3.5], - 'hint': [0.0, 2.5], - 'building': [0.0, 1.0, 2.0, 3.0, 3.5], - 'pressure': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'rising': [0.0, 1.0, 2.0, 2.75, 3.0, 3.25, 3.5, 3.75], - 'tension': [0.0, 0.25, 1.0, 1.5, 2.0, 2.75, 3.0, 3.25, 3.5], - 'full': [0.0, 1.0, 2.0, 3.0], - 'punch': [0.0, 0.25, 1.0, 2.0, 3.0], - 'groove': [0.0, 0.75, 1.0, 1.75, 2.0, 2.75, 3.0, 3.75], - 'impact': [0.0, 0.25, 0.5, 1.0, 2.0, 3.0], - 'fading': [0.0, 2.0], - 'strip_down': [0.0], - 'absent': [], - }, - 'clap': { - 'backbeat': [1.0, 3.0], - 'sparse': [1.0], - 'hint': [3.0], - 'building': [1.0, 2.5, 3.0], - 'anticipate': [1.0, 2.0, 2.75, 3.0, 3.5], - 'roll_in': [0.75, 1.0, 1.25, 1.5, 2.75, 3.0, 3.25, 3.5], - 'full': [1.0, 3.0], - 'syncopated': [0.75, 1.0, 2.75, 3.0], - 'offbeat': [1.5, 3.5], - 'punch': [0.75, 1.0, 1.25, 2.75, 3.0, 3.25], - 'ghost': [3.0], - 'last_hit': [1.0], - 'fading': [1.0], - 'absent': [], - }, - 'hat_closed': { - 'offbeat': [0.5, 1.5, 2.5, 3.5], - 'sparse': [0.5, 2.5], - 'ghost': [0.25, 1.25, 2.25, 3.25], - 'whisper': [0.75, 1.75, 2.75, 3.75], - 'building': [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'open_up': [0.5, 0.75, 1.5, 1.75, 2.5, 2.75, 3.5, 3.75], - 'hyper': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5, 3.75], - 'full': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'groove': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'shuffle': [0.0, 0.33, 0.66, 1.0, 1.33, 1.66, 2.0, 2.33, 2.66, 3.0, 3.33, 3.66], - 'filtered': [0.5, 1.5, 2.5, 3.5], - 'energy': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'fading': [0.5, 2.5], - 'minimal': [0.5], - }, - 'hat_open': { - 'sparse': [2.0], - 'building': [1.5, 2.5, 3.0], - 'full': [0.0, 2.0], - 'offbeat': [1.5, 3.5], - 'tease': [3.5], - 'fading': [2.0], - 'last_hit': [3.5], - 'hint': [2.0], - 'absent': [], - }, - 'perc': { - 'minimal': [1.5], - 'atmos': [0.75, 2.75], - 'ghost': [0.25, 2.25], - 'layering': [0.5, 1.5, 2.5, 3.5], - 'tension': [0.25, 1.25, 2.25, 3.25], - 'build_up': [0.5, 1.0, 2.0, 3.0, 3.5], - 'full': [0.5, 1.0, 1.5, 2.5, 3.0, 3.5], - 'layered': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], - 'groove': [0.5, 1.0, 2.0, 2.5, 3.5], - 'latin': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], - 'tribal': [0.0, 0.5, 1.25, 1.75, 2.5, 3.0, 3.75], - 'filtered': [0.5, 2.5], - 'fading': [1.5], - 'strip_down': [0.0], - 'hint': [2.0], - }, - 'ride': { - 'sparse': [0.0, 2.0], - 'building': [0.0, 1.0, 2.0, 3.0], - 'rising': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], - 'full': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'groove': [0.0, 0.25, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'energy': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.25, 3.5], - 'filtered': [0.0, 2.0], - 'fading': [0.0], - 'minimal': [0.0], - 'absent': [], - }, - 'top_loop': { - 'minimal': [0.25, 1.25, 2.25, 3.25], - 'energy': [0.0, 0.25, 0.5, 1.0, 1.25, 1.5, 2.0, 2.25, 2.5, 3.0, 3.25, 3.5], - 'building': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], - 'full': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'layered': [0.25, 0.5, 0.75, 1.25, 1.5, 1.75, 2.25, 2.5, 2.75, 3.25, 3.5, 3.75], - 'filtered': [0.5, 1.5, 2.5, 3.5], - 'fading': [0.5, 2.5], - 'hint': [1.5, 3.5], - 'absent': [], - }, - 'snare_fill': { - 'rolling': [2.0, 2.125, 2.25, 2.375, 2.5, 2.625, 2.75, 2.875, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875], - 'tension': [3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875], - 'drop_hit': [0.0], - 'fill': [3.0, 3.25, 3.5, 3.75], - 'end_fill': [0.0, 0.25, 0.5, 0.75], - 'absent': [], - }, - 'tom_fill': { - 'rising': [3.0, 3.2, 3.4, 3.6, 3.8], - 'fill': [3.0, 3.125, 3.25, 3.375, 3.5], - 'drop_hit': [0.0], - 'tension': [3.5, 3.625, 3.75, 3.875], - 'end_fill': [0.0, 0.2, 0.4, 0.6], - 'absent': [], - }, -} - -# Section-specific bass variants - EXPANDED -BASS_SECTION_VARIANTS = { - 'intro': ['subtle', 'hint', 'foreshadow', 'ghost', 'minimal'], - 'build': ['rising', 'tension', 'anticipate', 'building', 'pressure'], - 'drop': ['full', 'punch', 'groove', 'deep', 'impact', 'energy', 'rolling'], - 'break': ['sparse', 'minimal', 'atmos', 'filtered', 'foreshadow'], - 'outro': ['fading', 'minimal', 'subtle', 'strip_down'], -} - -# Expanded bass pattern templates (relative positions in 4-bar cycle) -BASS_PATTERN_BANKS = { - 'anchor': { - 'positions': [0.0, 1.0, 2.0, 3.0], - 'durations': [0.5, 0.5, 0.5, 0.5], - 'style': 'root_heavy' - }, - 'subtle': { - 'positions': [0.0, 2.0], - 'durations': [0.3, 0.3], - 'style': 'minimal' - }, - 'hint': { - 'positions': [0.0, 3.5], - 'durations': [0.25, 0.25], - 'style': 'foreshadow' - }, - 'foreshadow': { - 'positions': [0.0, 1.0, 3.0, 3.5], - 'durations': [0.4, 0.3, 0.4, 0.3], - 'style': 'building' - }, - 'ghost': { - 'positions': [0.5, 2.5], - 'durations': [0.2, 0.2], - 'style': 'minimal' - }, - 'rising': { - 'positions': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.5, 0.4], - 'style': 'ascending' - }, - 'tension': { - 'positions': [0.0, 0.75, 1.5, 2.25, 3.0, 3.5], - 'durations': [0.5, 0.25, 0.5, 0.25, 0.5, 0.3], - 'style': 'syncopated' - }, - 'anticipate': { - 'positions': [0.0, 1.0, 2.0, 2.75, 3.0, 3.25, 3.5], - 'durations': [0.5, 0.5, 0.4, 0.2, 0.4, 0.2, 0.4], - 'style': 'building' - }, - 'building': { - 'positions': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.25, 3.5, 3.75], - 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.3, 0.2, 0.3, 0.2], - 'style': 'ascending' - }, - 'pressure': { - 'positions': [0.0, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 2.5, 3.0, 3.25, 3.5, 3.75], - 'durations': [0.3, 0.2, 0.3, 0.2, 0.4, 0.4, 0.4, 0.4, 0.3, 0.2, 0.3, 0.2], - 'style': 'intense' - }, - 'full': { - 'positions': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'durations': [0.5, 0.4, 0.5, 0.4, 0.5, 0.4, 0.5, 0.4], - 'style': 'groove' - }, - 'punch': { - 'positions': [0.0, 0.25, 1.0, 2.0, 3.0], - 'durations': [0.6, 0.2, 0.5, 0.5, 0.5], - 'style': 'punchy' - }, - 'groove': { - 'positions': [0.0, 0.25, 0.75, 1.0, 1.75, 2.0, 2.75, 3.0, 3.5], - 'durations': [0.4, 0.2, 0.3, 0.4, 0.3, 0.4, 0.3, 0.4, 0.3], - 'style': 'syncopated' - }, - 'deep': { - 'positions': [0.0, 1.0, 2.0, 3.0], - 'durations': [0.8, 0.8, 0.8, 0.8], - 'style': 'sub' - }, - 'impact': { - 'positions': [0.0, 0.5, 1.5, 2.0, 3.0, 3.5], - 'durations': [0.6, 0.4, 0.3, 0.5, 0.5, 0.4], - 'style': 'punchy' - }, - 'energy': { - 'positions': [0.0, 0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'durations': [0.4, 0.25, 0.4, 0.5, 0.4, 0.5, 0.4, 0.5, 0.4], - 'style': 'driving' - }, - 'rolling': { - 'positions': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5, 3.75], - 'durations': [0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15, 0.2, 0.15], - 'style': 'rolling' - }, - 'sparse': { - 'positions': [0.0, 2.0], - 'durations': [0.4, 0.4], - 'style': 'minimal' - }, - 'minimal': { - 'positions': [0.0], - 'durations': [0.3], - 'style': 'hint' - }, - 'atmos': { - 'positions': [0.0, 3.0], - 'durations': [0.6, 0.4], - 'style': 'atmospheric' - }, - 'filtered': { - 'positions': [0.0, 1.5, 2.5], - 'durations': [0.4, 0.3, 0.3], - 'style': 'filtered' - }, - 'fading': { - 'positions': [0.0, 2.0], - 'durations': [0.5, 0.3], - 'style': 'decay' - }, - 'strip_down': { - 'positions': [0.0], - 'durations': [0.25], - 'style': 'minimal' - }, - 'bounce': { - 'positions': [0.0, 0.5, 1.5, 2.0, 2.5, 3.5], - 'durations': [0.4, 0.3, 0.4, 0.4, 0.3, 0.4], - 'style': 'bouncy' - }, - 'syncopated': { - 'positions': [0.25, 0.75, 1.25, 1.75, 2.25, 2.75, 3.25, 3.75], - 'durations': [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2], - 'style': 'offbeat' - }, -} - -# Pattern variant diversity memory - track used variants across generations -_pattern_variant_memory: Dict[str, Dict[str, int]] = { - 'drum': {}, - 'bass': {}, - 'melodic': {}, -} - -def _get_pattern_variant_penalty(category: str, variant: str) -> float: - """Get penalty for a pattern variant based on cross-generation usage.""" - if variant in _pattern_variant_memory.get(category, {}): - count = _pattern_variant_memory[category].get(variant, 0) - return min(0.4, count * 0.08) - return 0.0 - -def _record_pattern_variant_usage(category: str, variant: str) -> None: - """Record that a pattern variant was used.""" - if category not in _pattern_variant_memory: - _pattern_variant_memory[category] = {} - _pattern_variant_memory[category][variant] = _pattern_variant_memory[category].get(variant, 0) + 1 - -def _decay_pattern_variant_memory() -> None: - """Decay pattern variant memory to allow reuse after generations.""" - for category in _pattern_variant_memory: - for variant in list(_pattern_variant_memory[category].keys()): - _pattern_variant_memory[category][variant] = max(0, _pattern_variant_memory[category][variant] - 1) - if _pattern_variant_memory[category][variant] <= 0: - del _pattern_variant_memory[category][variant] - -def reset_pattern_variant_memory() -> None: - """Reset all pattern variant memory.""" - global _pattern_variant_memory - _pattern_variant_memory = {'drum': {}, 'bass': {}, 'melodic': {}} - -# Expanded fill patterns for section transitions -FILL_PATTERNS = { - 'drum_fill_4bar': { - 'roles': ['snare', 'kick', 'hat'], - 'pattern': { - 'snare': [3.0, 3.25, 3.5, 3.75], - 'kick': [3.5], - 'hat': [3.0, 3.5] - }, - 'velocities': {'snare': 100, 'kick': 90, 'hat': 70} - }, - 'drum_fill_2bar': { - 'roles': ['snare', 'hat'], - 'pattern': { - 'snare': [1.5, 1.75], - 'hat': [1.5] - }, - 'velocities': {'snare': 95, 'hat': 65} - }, - 'snare_roll': { - 'roles': ['snare'], - 'pattern': { - 'snare': [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.875] - }, - 'velocities': {'snare': 85} - }, - 'hat_open_build': { - 'roles': ['hat_open'], - 'pattern': { - 'hat_open': [0.0, 0.5, 1.0, 1.5, 2.0, 2.25, 2.5, 2.75, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875] - }, - 'velocities': {'hat_open': 75} - }, - 'kick_drop': { - 'roles': ['kick'], - 'pattern': { - 'kick': [0.0] - }, - 'velocities': {'kick': 127} - }, - 'crash_impact': { - 'roles': ['crash'], - 'pattern': { - 'crash': [0.0] - }, - 'velocities': {'crash': 100} - }, - 'snare_roll_build': { - 'roles': ['snare', 'hat'], - 'pattern': { - 'snare': [2.0, 2.25, 2.5, 2.75, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875], - 'hat': [2.0, 2.5, 3.0, 3.5] - }, - 'velocities': {'snare': 88, 'hat': 70} - }, - 'tom_build': { - 'roles': ['tom_fill'], - 'pattern': { - 'tom_fill': [2.0, 2.2, 2.4, 2.6, 2.8, 3.0, 3.2, 3.4, 3.6, 3.8] - }, - 'velocities': {'tom_fill': 90} - }, - 'full_impact': { - 'roles': ['kick', 'snare', 'crash'], - 'pattern': { - 'kick': [0.0], - 'snare': [0.0, 0.25], - 'crash': [0.0] - }, - 'velocities': {'kick': 127, 'snare': 110, 'crash': 105} - }, - 'hat_tension': { - 'roles': ['hat_closed'], - 'pattern': { - 'hat_closed': [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0, 1.125, 1.25, 1.375, 1.5, 1.625, 1.75, 1.875] - }, - 'velocities': {'hat_closed': 72} - }, - 'percussion_fill': { - 'roles': ['perc'], - 'pattern': { - 'perc': [0.5, 0.75, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5] - }, - 'velocities': {'perc': 78} - }, - 'minimal_drop': { - 'roles': ['kick'], - 'pattern': { - 'kick': [0.0] - }, - 'velocities': {'kick': 120} - }, - 'build_tension': { - 'roles': ['snare', 'hat_closed', 'kick'], - 'pattern': { - 'snare': [2.5, 2.75, 3.0, 3.25, 3.5, 3.75], - 'hat_closed': [2.0, 2.5, 3.0, 3.5], - 'kick': [0.0] - }, - 'velocities': {'snare': 92, 'hat_closed': 68, 'kick': 95} - }, - 'outro_fade': { - 'roles': ['hat_closed', 'perc'], - 'pattern': { - 'hat_closed': [0.0, 0.5, 1.0], - 'perc': [0.25, 0.75, 1.25] - }, - 'velocities': {'hat_closed': 80, 'perc': 70} - }, -} - -# Expanded transition events between sections -TRANSITION_EVENTS = { - ('intro', 'build'): ['hat_tension', 'hat_open_build'], - ('build', 'drop'): ['full_impact', 'crash_impact', 'kick_drop', 'snare_roll_build'], - ('drop', 'break'): ['drum_fill_4bar', 'percussion_fill'], - ('break', 'build'): ['hat_tension', 'hat_open_build'], - ('break', 'drop'): ['crash_impact', 'kick_drop', 'full_impact'], - ('drop', 'outro'): ['drum_fill_2bar', 'outro_fade'], - ('outro', 'end'): ['minimal_drop'], -} - -# Rules for preventing transition overcrowding -TRANSITION_DENSITY_RULES = { - # Max fills per section kind - 'max_fills_by_section': { - 'intro': 1, # Minimal fills in intro - 'build': 3, # More fills for tension - 'drop': 2, # Moderate fills - 'break': 2, # Sparse - 'outro': 1, # Minimal - }, - - # Events that should not stack together - 'exclusive_events': [ - {'crash_impact', 'kick_drop'}, # Don't stack impact events - {'drum_fill_4bar', 'snare_roll'}, # Choose one drum fill - ], - - # Minimum distance between same-type fills (in beats) - 'min_distance_same_type': { - 'crash_impact': 8.0, - 'kick_drop': 16.0, - 'snare_roll': 4.0, - } -} - -# Section-specific melodic variants - EXPANDED -MELODIC_SECTION_VARIANTS = { - 'intro': ['subtle', 'foreshadow', 'atmospheric', 'ghost', 'hint'], - 'build': ['rising', 'tension', 'anticipate', 'building', 'energy'], - 'drop': ['hook', 'anthem', 'full', 'punchy', 'impact', 'driving'], - 'break': ['sparse', 'minimal', 'ethereal', 'filtered', 'atmospheric'], - 'outro': ['fading', 'echo', 'minimal', 'strip_down', 'decay'], -} - -# Expanded melodic pattern templates -MELODIC_PATTERN_BANKS = { - 'motif': { - 'intervals': [0, 4, 7, 0], - 'rhythm': [0.0, 0.5, 1.0, 1.5], - 'durations': [0.4, 0.3, 0.4, 0.3], - 'style': 'repeating' - }, - 'subtle': { - 'intervals': [0, 0], - 'rhythm': [0.0, 2.0], - 'durations': [0.3, 0.3], - 'style': 'minimal' - }, - 'foreshadow': { - 'intervals': [0, 4, 0], - 'rhythm': [0.0, 1.0, 3.5], - 'durations': [0.4, 0.3, 0.5], - 'style': 'hint' - }, - 'atmospheric': { - 'intervals': [0, 2, 4, 5, 7], - 'rhythm': [0.0, 0.8, 1.6, 2.4, 3.2], - 'durations': [0.8, 0.7, 0.6, 0.5, 0.4], - 'style': 'pad' - }, - 'ghost': { - 'intervals': [0, 7], - 'rhythm': [0.5, 2.5], - 'durations': [0.2, 0.2], - 'style': 'minimal' - }, - 'hint': { - 'intervals': [0, 5], - 'rhythm': [0.0, 3.0], - 'durations': [0.25, 0.25], - 'style': 'minimal' - }, - 'rising': { - 'intervals': [0, 2, 4, 5, 7, 9, 11, 12], - 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'durations': [0.4, 0.35, 0.4, 0.35, 0.4, 0.35, 0.5, 0.4], - 'style': 'ascending' - }, - 'tension': { - 'intervals': [0, 1, 0, 1, 2, 1, 0], - 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], - 'durations': [0.3, 0.2, 0.3, 0.2, 0.3, 0.2, 0.5], - 'style': 'chromatic' - }, - 'anticipate': { - 'intervals': [0, 4, 7, 9, 12], - 'rhythm': [0.0, 1.0, 2.0, 3.0, 3.75], - 'durations': [0.5, 0.4, 0.5, 0.3, 0.5], - 'style': 'buildup' - }, - 'building': { - 'intervals': [0, 2, 4, 5, 7, 9, 11], - 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.75, 3.5], - 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.5], - 'style': 'ascending' - }, - 'energy': { - 'intervals': [0, 4, 7, 9, 12, 14], - 'rhythm': [0.0, 0.25, 0.75, 1.25, 2.0, 2.75], - 'durations': [0.3, 0.25, 0.3, 0.25, 0.4, 0.5], - 'style': 'driving' - }, - 'hook': { - 'intervals': [0, 4, 7, 4, 0, 4, 7, 12], - 'rhythm': [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75], - 'durations': [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.3], - 'style': 'catchy' - }, - 'anthem': { - 'intervals': [0, 4, 7, 12, 11, 7, 4, 0], - 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'durations': [0.4, 0.4, 0.4, 0.5, 0.4, 0.4, 0.4, 0.5], - 'style': 'big' - }, - 'full': { - 'intervals': [0, 4, 7, 5, 4, 2, 0], - 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], - 'durations': [0.4, 0.3, 0.4, 0.3, 0.4, 0.3, 0.5], - 'style': 'melodic' - }, - 'punchy': { - 'intervals': [0, 7, 0, 12], - 'rhythm': [0.0, 0.25, 0.5, 0.75], - 'durations': [0.15, 0.15, 0.15, 0.2], - 'style': 'staccato' - }, - 'impact': { - 'intervals': [0, 5, 7, 12, 7, 5], - 'rhythm': [0.0, 0.5, 0.75, 1.5, 2.25, 3.0], - 'durations': [0.4, 0.25, 0.3, 0.5, 0.3, 0.4], - 'style': 'driving' - }, - 'driving': { - 'intervals': [0, 4, 7, 4, 0, 4, 5, 7], - 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5], - 'durations': [0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.4], - 'style': 'repeating' - }, - 'sparse': { - 'intervals': [0, 7], - 'rhythm': [0.0, 2.0], - 'durations': [0.4, 0.4], - 'style': 'minimal' - }, - 'minimal': { - 'intervals': [0], - 'rhythm': [0.0], - 'durations': [0.3], - 'style': 'single' - }, - 'ethereal': { - 'intervals': [0, 7, 12, 7], - 'rhythm': [0.0, 1.5, 2.5, 3.5], - 'durations': [1.0, 0.8, 1.0, 0.8], - 'style': 'pad' - }, - 'filtered': { - 'intervals': [0, 4, 7, 5], - 'rhythm': [0.0, 1.0, 2.0, 3.0], - 'durations': [0.5, 0.4, 0.5, 0.4], - 'style': 'filtered' - }, - 'fading': { - 'intervals': [0, 4, 0], - 'rhythm': [0.0, 1.0, 2.0], - 'durations': [0.5, 0.4, 0.3], - 'style': 'decay' - }, - 'echo': { - 'intervals': [0, 0, 0], - 'rhythm': [0.0, 0.5, 1.0], - 'durations': [0.3, 0.25, 0.2], - 'style': 'repeat' - }, - 'response': { - 'intervals': [7, 4, 0], - 'rhythm': [0.5, 1.5, 2.5], - 'durations': [0.3, 0.3, 0.4], - 'style': 'call_response' - }, - 'lift': { - 'intervals': [0, 4, 7, 12, 14, 16], - 'rhythm': [0.0, 0.5, 1.0, 1.5, 2.0, 2.5], - 'durations': [0.3, 0.3, 0.3, 0.4, 0.3, 0.4], - 'style': 'ascending' - }, - 'strip_down': { - 'intervals': [0], - 'rhythm': [0.0], - 'durations': [0.25], - 'style': 'minimal' - }, - 'decay': { - 'intervals': [0, 7, 5, 3], - 'rhythm': [0.0, 1.0, 2.0, 3.0], - 'durations': [0.5, 0.4, 0.3, 0.2], - 'style': 'descending' - }, - 'call_response': { - 'intervals': [0, 4, 7, 0, 7, 4], - 'rhythm': [0.0, 0.25, 0.5, 1.5, 2.0, 2.5], - 'durations': [0.25, 0.2, 0.3, 0.35, 0.25, 0.3], - 'style': 'call_response' - }, -} - -# ============================================================================= -# MASTER CHAIN AUTOMATION TARGETS -# ============================================================================= - - -@dataclass -class StyleConfig: - """Configuración de estilo musical""" - genre: str - bpm: float - key: str - scale: str - density: str # minimal, normal, busy - complexity: str # simple, moderate, complex - - -class SongGenerator: - """Generador de configuraciones y patrones musicales""" - - def __init__(self): - self.logger = logging.getLogger("SongGenerator") - self._current_generation_profile = { - 'name': 'default', - 'seed': 0, - 'drum_tightness': 1.0, - 'bass_motion': 'locked', - 'melodic_motion': 'restrained', - 'pan_width': 0.12, - 'fx_bias': 1.0, - } - # Track style adjustments and calibrated volumes for this generation - self._style_adjustments_applied = [] - self._calibrated_bus_volumes = {} - # Tracking for ROLE_GAIN_CALIBRATION overrides - self._gain_calibration_overrides_count = 0 - self._peak_reductions_count = 0 - self._master_profile_used = 'default' - - # ========================================================================= - # UTILIDADES MUSICALES - # ========================================================================= - - def note_name_to_midi(self, note_name: str, octave: int = 3) -> int: - """Convierte nombre de nota a número MIDI""" - note_name = note_name.replace('b', '#').replace('Db', 'C#').replace('Eb', 'D#') - note_name = note_name.replace('Gb', 'F#').replace('Ab', 'G#').replace('Bb', 'A#') - - try: - note_idx = NOTE_NAMES.index(note_name.upper()) - return (octave + 1) * 12 + note_idx - except ValueError: - return 60 # Default C4 - - def midi_to_note_name(self, midi_note: int) -> tuple: - """Convierte MIDI a (nota, octava)""" - octave = (midi_note // 12) - 1 - note_name = NOTE_NAMES[midi_note % 12] - return note_name, octave - - def get_scale_notes(self, root_note: Union[int, str], scale_name: str = 'minor') -> List[int]: - """Obtiene las notas de una escala""" - if isinstance(root_note, str): - root_midi = self.note_name_to_midi(root_note) - else: - root_midi = root_note - - scale_intervals = SCALES.get(scale_name, SCALES['minor']) - return [root_midi + interval for interval in scale_intervals] - - def quantize_to_scale(self, note: int, scale_notes: List[int]) -> int: - """Cuantiza una nota a la escala más cercana""" - if note in scale_notes: - return note - return min(scale_notes, key=lambda x: abs(x - note)) - - # ========================================================================= - # GENERACIÓN DE CONFIGURACIONES - # ========================================================================= - - def _make_note(self, pitch: int, start: float, duration: float, velocity: int) -> Dict[str, Any]: - return { - 'pitch': max(0, min(127, int(pitch))), - 'start': round(float(start), 3), - 'duration': round(max(0.05, float(duration)), 3), - 'velocity': max(1, min(127, int(velocity))), - } - - def _repeat_pattern(self, pattern: List[Dict[str, Any]], total_length: float, pattern_length: float = 4.0) -> List[Dict[str, Any]]: - if not pattern or total_length <= 0 or pattern_length <= 0: - return [] - - notes = [] - repeats = max(1, int(round(total_length / pattern_length))) - for repeat_index in range(repeats): - offset = repeat_index * pattern_length - for note in pattern: - start = float(note['start']) + offset - if start >= total_length: - continue - duration = min(float(note['duration']), total_length - start) - notes.append(self._make_note(note['pitch'], start, duration, note['velocity'])) - return notes - - def _section_rng(self, section: Dict[str, Any], role: str, salt: int = 0) -> random.Random: - base_seed = int(self._current_generation_profile.get('seed', 0)) - section_index = int(section.get('index', 0)) - role_fingerprint = sum((index + 1) * ord(char) for index, char in enumerate(str(role))) - return random.Random(base_seed + (section_index * 1009) + (role_fingerprint * 17) + (salt * 7919)) - - def _clamp_pan(self, value: float) -> float: - return round(max(-1.0, min(1.0, float(value))), 3) - - def _clamp_unit(self, value: float) -> float: - return round(max(0.0, min(1.0, float(value))), 3) - - def _apply_swing(self, notes: List[Dict[str, Any]], amount: float, section_length: float) -> List[Dict[str, Any]]: - if not notes or abs(amount) < 0.001: - return notes - - swung = [] - for note in notes: - start = float(note['start']) - fractional = round(start % 1.0, 3) - if 0.001 < fractional < 0.999: - shift = amount if fractional >= 0.5 else (amount * -0.45) - start = min(max(0.0, start + shift), max(0.0, section_length - 0.05)) - swung.append(self._make_note(note['pitch'], start, note['duration'], note['velocity'])) - swung.sort(key=lambda item: (item['start'], item['pitch'])) - return swung - - def _apply_density_mask(self, notes: List[Dict[str, Any]], section: Dict[str, Any], role: str, - keep_probability: float) -> List[Dict[str, Any]]: - if not notes or keep_probability >= 0.995: - return notes - - rng = self._section_rng(section, role, salt=3) - filtered = [] - for note in notes: - start = float(note['start']) - if abs(start % 1.0) < 0.001: - filtered.append(note) - continue - if rng.random() <= keep_probability: - filtered.append(note) - return filtered or notes[:1] - - def _build_arrangement_profile(self, genre: str, style: str, variant_seed: int) -> Dict[str, Any]: - style_text = "{} {}".format(genre, style).lower() - candidates = [profile for profile in ARRANGEMENT_PROFILES if genre in set(profile.get('genres', ()))] - - if 'latin' in style_text: - candidates = [profile for profile in ARRANGEMENT_PROFILES if profile['name'] in ['swing', 'jackin']] or candidates - elif 'industrial' in style_text: - candidates = [profile for profile in ARRANGEMENT_PROFILES if profile['name'] in ['warehouse', 'festival']] or candidates - - if not candidates: - candidates = list(ARRANGEMENT_PROFILES) - - rng = random.Random(int(variant_seed) + 41) - selected = dict(rng.choice(candidates)) - selected['seed'] = int(variant_seed) - return selected - - def _extend_parallel_sends(self, role: str, sends: Dict[str, Any]) -> Dict[str, Any]: - resolved = dict(sends or {}) - if role in ['kick', 'clap', 'hat_closed', 'hat_open', 'top_loop', 'perc', 'ride', 'snare_fill', 'tom_fill']: - resolved.setdefault('glue', 0.1) - resolved.setdefault('heat', 0.05) - elif role in ['sub_bass', 'bass', 'stab']: - resolved.setdefault('glue', 0.08) - resolved.setdefault('heat', 0.08) - elif role in ['chords', 'pad', 'pluck', 'arp', 'lead', 'counter', 'vocal']: - resolved.setdefault('glue', 0.04) - elif role in ['reverse_fx', 'riser', 'impact', 'atmos', 'drone', 'crash']: - resolved.setdefault('glue', 0.03) - return resolved - - def _resolve_bus_for_role(self, role: str) -> Optional[str]: - return ROLE_BUS_ASSIGNMENTS.get(str(role or '').strip().lower(), 'music') - - def _get_section_variation(self, role: str, section_kind: str) -> Dict[str, Any]: - """ - Obtiene configuración de variación para un rol y sección. - - Retorna dict con: - - use: bool - si el rol debe usarse en esta sección - - sparse: bool - si usar variante sparse - - full: bool - si usar variante completa - - intensity: float - intensidad de 0 a 1 - - etc. - """ - if role not in SECTION_VARIATION_CONFIG: - return {'use': True, 'intensity': 1.0} - - role_config = SECTION_VARIATION_CONFIG[role] - return role_config.get(section_kind.lower(), {'use': True, 'intensity': 1.0}) - - def _should_vary_role_in_section(self, role: str, section_kind: str) -> bool: - """Determina si un rol debe variar en una sección dada.""" - if role not in SECTION_VARIATION_CONFIG: - return False - - config = self._get_section_variation(role, section_kind) - - # Si tiene clave 'use' explícita - if 'use' in config: - return config['use'] - - # Si tiene variantes específicas - return any(k in config for k in ['sparse', 'full', 'building', 'fading']) - - def _build_mix_bus_blueprint( - self, - profile: Dict[str, Any], - genre: str, - style: str, - reference_resolution: Optional[Dict[str, Any]] = None, - ) -> List[Dict[str, Any]]: - style_text = f"{genre} {style}".lower() - profile_name = str(profile.get('name', 'default')).lower() - reference_name = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() - - buses = [ - { - 'key': 'drums', - 'name': 'DRUM BUS', - 'color': BUS_TRACK_COLORS['drums'], - 'volume': 0.86, - 'pan': 0.0, - 'monitoring': 'in', - 'fx_chain': [ - {'device': 'Compressor', 'parameters': {'Threshold': -16.5}}, - {'device': 'Saturator', 'parameters': {'Drive': 1.2}}, - {'device': 'Utility', 'parameters': {'Gain': 0.2}}, - {'device': 'Limiter', 'parameters': {'Gain': 0.3}}, - ], - }, - { - 'key': 'bass', - 'name': 'BASS BUS', - 'color': BUS_TRACK_COLORS['bass'], - 'volume': 0.8, - 'pan': 0.0, - 'monitoring': 'in', - 'fx_chain': [ - {'device': 'Saturator', 'parameters': {'Drive': 1.3}}, - {'device': 'Compressor', 'parameters': {'Threshold': -18.0}}, - {'device': 'Utility', 'parameters': {'Stereo Width': 0.0}}, - {'device': 'Utility', 'parameters': {'Gain': 0.2}}, - ], - }, - { - 'key': 'music', - 'name': 'MUSIC BUS', - 'color': BUS_TRACK_COLORS['music'], - 'volume': 0.8, - 'pan': 0.0, - 'monitoring': 'in', - 'fx_chain': [ - {'device': 'Compressor', 'parameters': {'Threshold': -21.0}}, - {'device': 'Auto Filter', 'parameters': {'Frequency': 12800.0, 'Dry/Wet': 0.05}}, - {'device': 'Utility', 'parameters': {'Stereo Width': 1.12}}, - {'device': 'Utility', 'parameters': {'Gain': 0.2}}, - ], - }, - { - 'key': 'vocal', - 'name': 'VOCAL BUS', - 'color': BUS_TRACK_COLORS['vocal'], - 'volume': 0.82, - 'pan': 0.0, - 'monitoring': 'in', - 'fx_chain': [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 0.05}}, - {'device': 'Compressor', 'parameters': {'Threshold': -18.0}}, - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.05}}, - {'device': 'Utility', 'parameters': {'Gain': 0.2}}, - ], - }, - { - 'key': 'fx', - 'name': 'FX BUS', - 'color': BUS_TRACK_COLORS['fx'], - 'volume': 0.76, - 'pan': 0.0, - 'monitoring': 'in', - 'fx_chain': [ - {'device': 'Auto Filter', 'parameters': {'Frequency': 10200.0, 'Dry/Wet': 0.1}}, - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.12}}, - {'device': 'Utility', 'parameters': {'Gain': -0.2}}, - {'device': 'Limiter', 'parameters': {'Gain': 0.0}}, - ], - }, - ] - - # ========================================================================= - # Apply BUS_GAIN_CALIBRATION as safe baseline BEFORE profile overrides - # ========================================================================= - self._style_adjustments_applied = [] - self._calibrated_bus_volumes = {} - - def find_device_in_chain(fx_chain, device_type): - for device in fx_chain: - if device.get('device') == device_type: - return device - return None - - for bus in buses: - bus_key = bus.get('key', '') - if bus_key not in BUS_GAIN_CALIBRATION: - continue - - calibration = BUS_GAIN_CALIBRATION[bus_key] - - if 'volume' in calibration: - bus['volume'] = calibration['volume'] - - fx_chain = bus.get('fx_chain', []) - - if 'compressor_threshold' in calibration: - compressor = find_device_in_chain(fx_chain, 'Compressor') - if compressor: - compressor['parameters']['Threshold'] = calibration['compressor_threshold'] - - if 'saturator_drive' in calibration: - saturator = find_device_in_chain(fx_chain, 'Saturator') - if saturator: - saturator['parameters']['Drive'] = calibration['saturator_drive'] - - if 'limiter_gain' in calibration: - limiter = find_device_in_chain(fx_chain, 'Limiter') - if limiter: - limiter['parameters']['Gain'] = calibration['limiter_gain'] - - if 'utility_gain' in calibration: - for device in fx_chain: - if device.get('device') == 'Utility': - if 'Gain' in device.get('parameters', {}): - device['parameters']['Gain'] = calibration['utility_gain'] - break - elif 'Stereo Width' not in device.get('parameters', {}): - device['parameters']['Gain'] = calibration['utility_gain'] - break - - # ========================================================================= - # Profile-specific overrides ON TOP of calibrated baselines - # ========================================================================= - if profile_name == 'warehouse': - buses[0]['name'] = 'DRUM BUNKER' - buses[0]['fx_chain'][1]['parameters']['Drive'] = 3.1 - buses[1]['name'] = 'LOW END BUS' - buses[1]['fx_chain'][0]['parameters']['Drive'] = 4.0 - buses[2]['fx_chain'][1]['parameters']['Frequency'] = 11200.0 - elif profile_name == 'festival': - buses[2]['name'] = 'MUSIC WIDE' - buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.14 - buses[3]['name'] = 'VOCAL TAIL' - buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.08 - buses[4]['name'] = 'FX WASH' - buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.14 - elif profile_name == 'swing': - buses[0]['name'] = 'DRUM POCKET' - buses[0]['fx_chain'][0]['parameters']['Threshold'] = -13.5 - buses[3]['name'] = 'VOCAL SLAP' - buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.12 - elif profile_name == 'jackin': - buses[0]['name'] = 'DRUM CLUB' - buses[2]['name'] = 'MUSIC JACK' - buses[3]['name'] = 'VOX CLUB' - buses[4]['name'] = 'FX JAM' - elif profile_name == 'tech-house-club': - # Club-oriented tech-house with punchy drums and latin vocal treatment - buses[0]['name'] = 'DRUM CLUB' - buses[0]['volume'] = 0.95 - buses[0]['fx_chain'][0]['parameters']['Threshold'] = -15.5 - buses[0]['fx_chain'][1]['parameters']['Drive'] = 2.2 - buses[1]['name'] = 'BASS TUBE' - buses[1]['volume'] = 0.95 - buses[1]['fx_chain'][0]['parameters']['Drive'] = 2.5 - buses[1]['fx_chain'][1]['parameters']['Threshold'] = -17.0 - buses[2]['name'] = 'MUSIC JACK' - buses[2]['volume'] = 0.95 - buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.16 - buses[3]['name'] = 'VOCAL LATIN BUS' - buses[3]['volume'] = 0.95 - buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.10 - buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.08 - buses[4]['name'] = 'FX JAM' - buses[4]['volume'] = 0.95 - buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.14 - elif profile_name == 'tech-house-deep': - # Minimal deep tech-house with subtle processing - buses[0]['name'] = 'DRUM DEEP' - buses[0]['volume'] = 0.95 - buses[0]['fx_chain'][0]['parameters']['Threshold'] = -18.0 - buses[0]['fx_chain'][1]['parameters']['Drive'] = 0.8 - buses[1]['name'] = 'SUB DEEP' - buses[1]['volume'] = 0.95 - buses[1]['fx_chain'][0]['parameters']['Drive'] = 1.0 - buses[1]['fx_chain'][1]['parameters']['Threshold'] = -20.0 - buses[2]['name'] = 'ATMOS DEEP' - buses[2]['volume'] = 0.95 - buses[2]['fx_chain'][0]['parameters']['Threshold'] = -24.0 - buses[2]['fx_chain'][1]['parameters']['Frequency'] = 10200.0 - buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.08 - buses[3]['name'] = 'VOX DEEP' - buses[3]['volume'] = 0.95 - buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.04 - buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.06 - buses[4]['name'] = 'FX DEEP' - buses[4]['volume'] = 0.95 - buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.08 - elif profile_name == 'tech-house-funky': - # Groovy tech-house with wide stereo and bouncy feel - buses[0]['name'] = 'DRUM GROOVE' - buses[0]['volume'] = 0.95 - buses[0]['fx_chain'][0]['parameters']['Threshold'] = -14.5 - buses[0]['fx_chain'][1]['parameters']['Drive'] = 1.8 - buses[1]['name'] = 'BASS FUNK' - buses[1]['volume'] = 0.95 - buses[1]['fx_chain'][0]['parameters']['Drive'] = 2.0 - buses[1]['fx_chain'][1]['parameters']['Threshold'] = -16.5 - buses[2]['name'] = 'MUSIC GROOVE' - buses[2]['volume'] = 0.95 - buses[2]['fx_chain'][0]['parameters']['Threshold'] = -20.0 - buses[2]['fx_chain'][2]['parameters']['Stereo Width'] = 1.20 - buses[3]['name'] = 'VOCAL FUNK' - buses[3]['volume'] = 0.95 - buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.12 - buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.10 - buses[4]['name'] = 'FX SWING' - buses[4]['volume'] = 0.95 - buses[4]['fx_chain'][1]['parameters']['Dry/Wet'] = 0.16 - - if 'industrial' in style_text: - buses[0]['fx_chain'][1]['parameters']['Drive'] = max( - 3.4, - float(buses[0]['fx_chain'][1]['parameters'].get('Drive', 2.2)), - ) - buses[1]['fx_chain'][0]['parameters']['Drive'] = max( - 4.2, - float(buses[1]['fx_chain'][0]['parameters'].get('Drive', 3.2)), - ) - if 'latin' in style_text or any(term in reference_name for term in ['me gusta', 'química', 'quimica']): - buses[3]['name'] = 'VOCAL LATIN BUS' - buses[3]['fx_chain'][0]['parameters']['Dry/Wet'] = 0.14 - buses[3]['fx_chain'][2]['parameters']['Dry/Wet'] = 0.08 - buses[0]['fx_chain'][0]['parameters']['Threshold'] = -14.0 - - # ========================================================================= - # Apply STYLE_GAIN_ADJUSTMENTS as multipliers AFTER profile overrides - # ========================================================================= - for style_key, adjustments in STYLE_GAIN_ADJUSTMENTS.items(): - if style_key.lower() in style_text: - self._style_adjustments_applied.append(style_key) - - # Apply bus volume factors - if 'drums_bus_volume_factor' in adjustments: - for bus in buses: - if bus.get('key') == 'drums': - bus['volume'] = bus.get('volume', 0.8) * adjustments['drums_bus_volume_factor'] - - if 'bass_bus_volume_factor' in adjustments: - for bus in buses: - if bus.get('key') == 'bass': - bus['volume'] = bus.get('volume', 0.8) * adjustments['bass_bus_volume_factor'] - - if 'vocal_bus_volume_factor' in adjustments: - for bus in buses: - if bus.get('key') == 'vocal': - bus['volume'] = bus.get('volume', 0.8) * adjustments['vocal_bus_volume_factor'] - - if 'music_bus_volume_factor' in adjustments: - for bus in buses: - if bus.get('key') == 'music': - bus['volume'] = bus.get('volume', 0.8) * adjustments['music_bus_volume_factor'] - - if 'fx_bus_volume_factor' in adjustments: - for bus in buses: - if bus.get('key') == 'fx': - bus['volume'] = bus.get('volume', 0.8) * adjustments['fx_bus_volume_factor'] - - # Apply saturator_drive_factor to all bus saturators - if 'saturator_drive_factor' in adjustments: - for bus in buses: - fx_chain = bus.get('fx_chain', []) - saturator = find_device_in_chain(fx_chain, 'Saturator') - if saturator and 'Drive' in saturator.get('parameters', {}): - saturator['parameters']['Drive'] = ( - saturator['parameters']['Drive'] * adjustments['saturator_drive_factor'] - ) - - # Apply limiter_gain_factor to all bus limiters - if 'limiter_gain_factor' in adjustments: - for bus in buses: - fx_chain = bus.get('fx_chain', []) - limiter = find_device_in_chain(fx_chain, 'Limiter') - if limiter and 'Gain' in limiter.get('parameters', {}): - limiter['parameters']['Gain'] = ( - limiter['parameters']['Gain'] * adjustments['limiter_gain_factor'] - ) - - # Store final calibrated bus volumes - for bus in buses: - bus_key = bus.get('key', '') - if bus_key: - self._calibrated_bus_volumes[bus_key] = bus.get('volume', 0.0) - - # RCA Fix: Automatic Makeup and Output gain compensation - for bus in buses: - for device in bus.get('fx_chain', []): - device_type = device.get('device') - params = device.get('parameters', {}) - if device_type == 'Compressor' and 'Threshold' in params: - params['Makeup'] = round(abs(params['Threshold']) * 0.25, 1) - elif device_type == 'Saturator' and 'Drive' in params: - params['Output'] = round(-params['Drive'] * 1.5, 1) - - return buses - - def _build_return_blueprint( - self, - profile: Dict[str, Any], - genre: str, - style: str, - reference_resolution: Optional[Dict[str, Any]] = None, - ) -> List[Dict[str, Any]]: - style_text = f"{genre} {style}".lower() - profile_name = str(profile.get('name', 'default')).lower() - reference_name = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() - returns = [ - { - 'name': 'MCP SPACE', - 'send_key': 'space', - 'color': 56, - 'device_chain': [{'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0}}], - 'volume': 0.76, - }, - { - 'name': 'MCP ECHO', - 'send_key': 'echo', - 'color': 44, - 'device_chain': [{'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}], - 'volume': 0.72, - }, - { - 'name': 'MCP HEAT', - 'send_key': 'heat', - 'color': 12, - 'device_chain': [ - {'device': 'Saturator', 'parameters': {'Drive': 4.5}}, - {'device': 'Compressor', 'parameters': {'Threshold': -16.0}}, - ], - 'volume': 0.62, - }, - { - 'name': 'MCP GLUE', - 'send_key': 'glue', - 'color': 58, - 'device_chain': [ - {'device': 'Compressor', 'parameters': {'Threshold': -18.0}}, - {'device': 'Limiter', 'parameters': {'Gain': 0.0}}, - ], - 'volume': 0.68, - }, - ] - - if profile_name == 'warehouse': - returns[0]['name'] = 'MCP BUNKER' - returns[0]['device_chain'] = [ - {'device': 'Auto Filter', 'parameters': {'Frequency': 7200.0, 'Dry/Wet': 0.22}}, - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0}}, - ] - returns[1]['name'] = 'MCP DUB' - returns[1]['device_chain'] = [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, - {'device': 'Auto Filter', 'parameters': {'Frequency': 8200.0, 'Dry/Wet': 0.14}}, - ] - returns[2]['device_chain'][0]['parameters']['Drive'] = 5.5 - returns[2]['volume'] = 0.66 - elif profile_name == 'festival': - returns[0]['name'] = 'MCP WIDE' - returns[0]['device_chain'] = [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0}}, - {'device': 'Utility', 'parameters': {'Stereo Width': 1.14}}, - ] - returns[1]['name'] = 'MCP TAIL' - returns[1]['device_chain'] = [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.18}}, - ] - returns[0]['volume'] = 0.72 - returns[1]['volume'] = 0.68 - elif profile_name == 'swing': - returns[0]['name'] = 'MCP ROOM' - returns[1]['name'] = 'MCP SLAP' - returns[1]['device_chain'] = [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, - {'device': 'Auto Filter', 'parameters': {'Frequency': 9800.0, 'Dry/Wet': 0.1}}, - ] - returns[2]['volume'] = 0.58 - elif profile_name == 'jackin': - returns[0]['name'] = 'MCP CLUB' - returns[1]['name'] = 'MCP SWING' - returns[2]['device_chain'][0]['parameters']['Drive'] = 3.8 - returns[3]['volume'] = 0.72 - elif profile_name == 'tech-house-club': - # Short reverb, mono delay, wide FX for club tech-house - returns[0]['name'] = 'REVERB SHORT' - returns[0]['device_chain'] = [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0, 'Decay Time': 0.6}}, - {'device': 'Auto Filter', 'parameters': {'Frequency': 8400.0, 'Dry/Wet': 0.08}}, - ] - returns[0]['volume'] = 0.70 - returns[1]['name'] = 'DELAY MONO' - returns[1]['device_chain'] = [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0, 'Ping Pong': 0.0}}, - {'device': 'Utility', 'parameters': {'Width': 0.0}}, - ] - returns[1]['volume'] = 0.68 - returns[2]['name'] = 'DRIVE HOT' - returns[2]['device_chain'][0]['parameters']['Drive'] = 4.0 - returns[2]['volume'] = 0.64 - returns[3]['name'] = 'GLUE BUS' - returns[3]['device_chain'][0]['parameters']['Threshold'] = -16.5 - returns[3]['volume'] = 0.70 - elif profile_name == 'tech-house-deep': - # Deep minimal returns with subtle processing - returns[0]['name'] = 'REVERB DEEP' - returns[0]['device_chain'] = [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0, 'Decay Time': 1.2}}, - {'device': 'Auto Filter', 'parameters': {'Frequency': 6200.0, 'Dry/Wet': 0.12}}, - ] - returns[0]['volume'] = 0.72 - returns[1]['name'] = 'DELAY DEEP' - returns[1]['device_chain'] = [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0, 'Feedback': 0.45}}, - ] - returns[1]['volume'] = 0.64 - returns[2]['name'] = 'SATURATE DEEP' - returns[2]['device_chain'][0]['parameters']['Drive'] = 2.5 - returns[2]['volume'] = 0.56 - returns[3]['name'] = 'GLUE MINIMAL' - returns[3]['device_chain'][0]['parameters']['Threshold'] = -20.0 - returns[3]['volume'] = 0.62 - elif profile_name == 'tech-house-funky': - # Groovy returns with modulation and swing - returns[0]['name'] = 'REVERB GROOVE' - returns[0]['device_chain'] = [ - {'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 1.0, 'Decay Time': 0.8}}, - {'device': 'Chorus-Ensemble', 'parameters': {'Dry/Wet': 0.08}}, - ] - returns[0]['volume'] = 0.74 - returns[1]['name'] = 'DELAY GROOVE' - returns[1]['device_chain'] = [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0, 'Ping Pong': 0.4, 'Feedback': 0.35}}, - {'device': 'Auto Filter', 'parameters': {'Frequency': 8000.0, 'Dry/Wet': 0.1}}, - ] - returns[1]['volume'] = 0.70 - returns[2]['name'] = 'DRIVE FUNK' - returns[2]['device_chain'][0]['parameters']['Drive'] = 3.2 - returns[2]['device_chain'].append({'device': 'Chorus-Ensemble', 'parameters': {'Dry/Wet': 0.06}}) - returns[2]['volume'] = 0.60 - returns[3]['name'] = 'GLUE SWING' - returns[3]['device_chain'][0]['parameters']['Threshold'] = -15.5 - returns[3]['volume'] = 0.72 - - if 'latin' in style_text or any(term in reference_name for term in ['me gusta', 'química', 'quimica']): - returns[1]['name'] = 'MCP VOX ECHO' - returns[1]['device_chain'] = [ - {'device': 'Echo', 'parameters': {'Dry/Wet': 1.0}}, - {'device': 'Auto Filter', 'parameters': {'Frequency': 10800.0, 'Dry/Wet': 0.12}}, - ] - returns[0]['volume'] = max(0.68, float(returns[0]['volume']) - 0.04) - if 'industrial' in style_text: - returns[2]['name'] = 'MCP DRIVE' - returns[2]['device_chain'][0]['parameters']['Drive'] = max( - 4.8, - float(returns[2]['device_chain'][0]['parameters'].get('Drive', 4.5)) - ) - returns[3]['name'] = 'MCP BUS' - - return returns - - def _build_master_blueprint( - self, - profile: Dict[str, Any], - genre: str, - style: str, - reference_resolution: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - style_text = f"{genre} {style}".lower() - profile_name = str(profile.get('name', 'default')).lower() - reference_name = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() - - # Start with default calibration values - calibration = dict(MASTER_CALIBRATION.get('default', {})) - - # Find matching profile (case-insensitive, partial match) - matched_profile = 'default' - profile_name_lower = profile_name.lower() - for cal_key in MASTER_CALIBRATION.keys(): - if cal_key.lower() in profile_name_lower or profile_name_lower in cal_key.lower(): - # Merge profile-specific values over defaults - profile_cal = MASTER_CALIBRATION[cal_key] - calibration.update(profile_cal) - matched_profile = cal_key - break - - # Track which profile was used - self._master_profile_used = matched_profile - - # Build master with calibrated values - # Master chain: Utility (gain staging) -> Saturator (color) -> Compressor (glue) -> Limiter (ceiling) - # Target: -1dB peak before limiter, -0.3dBFS ceiling after limiter - master = { - 'volume': calibration.get('volume', 0.85), - 'device_chain': [ - { - 'device': 'Utility', - 'parameters': { - 'Gain': calibration.get('utility_gain', -0.5), - 'Stereo Width': calibration.get('stereo_width', 1.04), - } - }, - { - 'device': 'Saturator', - 'parameters': {'Drive': calibration.get('saturator_drive', 0.12)} - }, - { - 'device': 'Compressor', - 'parameters': { - 'Ratio': calibration.get('compressor_ratio', 0.50), - 'Attack': calibration.get('compressor_attack', 0.30), - 'Release': calibration.get('compressor_release', 0.20), - } - }, - { - 'device': 'Limiter', - 'parameters': { - 'Gain': calibration.get('limiter_gain', 0.8), - 'Ceiling': calibration.get('limiter_ceiling', -0.3), - } - }, - ], - } - - # Apply style-based limiter_gain_factor from STYLE_GAIN_ADJUSTMENTS - for style_key, style_adj in STYLE_GAIN_ADJUSTMENTS.items(): - if style_key.lower() in style_text: - limiter_factor = style_adj.get('limiter_gain_factor') - if limiter_factor is not None: - master['device_chain'][3]['parameters']['Gain'] *= limiter_factor - break - - if 'industrial' in style_text: - master['device_chain'][1]['parameters']['Drive'] = max( - 0.8, - float(master['device_chain'][1]['parameters'].get('Drive', 0.3)) - ) - master['device_chain'][2]['parameters']['Ratio'] = max( - 0.7, - float(master['device_chain'][2]['parameters'].get('Ratio', 0.62)) - ) - - if 'latin' in style_text or any(term in reference_name for term in ['me gusta', 'química', 'quimica']): - master['device_chain'][0]['parameters']['Stereo Width'] = max( - 1.14, - float(master['device_chain'][0]['parameters'].get('Stereo Width', 1.1)) - ) - master['device_chain'][3]['parameters']['Gain'] = max( - 0.1, - float(master['device_chain'][3]['parameters'].get('Gain', 0.0)) - ) - - return master - - def _apply_role_gain_calibration(self, role: str, base_volume: float) -> Dict[str, float]: - """ - Apply ROLE_GAIN_CALIBRATION to a role's volume. - - Args: - role: The role name (e.g., 'kick', 'bass', 'clap') - base_volume: The base volume from ROLE_MIX - - Returns: - Dict with 'volume' and optionally 'saturator_drive' if calibrated - """ - if role not in ROLE_GAIN_CALIBRATION: - return {'volume': base_volume} - - calibration = ROLE_GAIN_CALIBRATION[role] - calibrated_volume = float(calibration.get('volume', base_volume)) - - # Apply peak_reduction if present - peak_reduction = calibration.get('peak_reduction', 0.0) - if peak_reduction > 0: - calibrated_volume *= (1.0 - float(peak_reduction)) - self._peak_reductions_count += 1 - - result = {'volume': round(max(0.0, min(1.0, calibrated_volume)), 3)} - - # Include saturator_drive if present in calibration - if 'saturator_drive' in calibration: - result['saturator_drive'] = float(calibration['saturator_drive']) - - self._gain_calibration_overrides_count += 1 - - return result - - def _shape_mix_profile(self, role: str, mix_profile: Dict[str, Any], profile: Dict[str, Any], style: str) -> Dict[str, Any]: - shaped = { - 'volume': float(mix_profile.get('volume', 0.72)), - 'pan': float(mix_profile.get('pan', 0.0)), - 'sends': dict(mix_profile.get('sends', {})), - } - - # Apply ROLE_GAIN_CALIBRATION if available - overrides base volume - calibration = self._apply_role_gain_calibration(role, shaped['volume']) - if calibration.get('volume') is not None: - shaped['volume'] = calibration['volume'] - if calibration.get('saturator_drive') is not None: - shaped['saturator_drive'] = calibration['saturator_drive'] - - profile_name = str(profile.get('name', 'default')).lower() - pan_width = float(profile.get('pan_width', 0.16) or 0.16) - style_text = str(style or '').lower() - - if role in ['hat_closed', 'hat_open', 'top_loop', 'perc', 'ride', 'pluck', 'arp', 'counter', 'vocal']: - shaped['pan'] = max(-1.0, min(1.0, shaped['pan'] * (1.0 + pan_width))) - - if profile_name == 'warehouse': - if role in ['kick', 'bass', 'sub_bass']: - shaped['volume'] *= 1.03 - if role in ['pad', 'drone', 'atmos']: - shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 0.88 - if role in ['reverse_fx', 'riser', 'impact']: - shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.08) - elif profile_name == 'festival': - if role in ['lead', 'chords', 'pad', 'arp', 'vocal']: - shaped['volume'] *= 1.04 - shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 1.15 - if role in ['kick', 'clap']: - shaped['sends']['glue'] = max(shaped['sends'].get('glue', 0.0), 0.12) - elif profile_name == 'swing': - if role in ['perc', 'top_loop', 'ride', 'vocal', 'pluck']: - shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.14 - if role in ['kick', 'sub_bass']: - shaped['volume'] *= 0.98 - elif profile_name == 'jackin': - if role in ['clap', 'perc', 'vocal', 'counter']: - shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.08 - if role in ['top_loop', 'ride']: - shaped['volume'] *= 1.03 - elif profile_name == 'tech-house-club': - # Club-oriented: punchy drums, present vocals, tight bass - if role in ['kick', 'clap']: - shaped['volume'] *= 1.02 - shaped['sends']['glue'] = max(shaped['sends'].get('glue', 0.0), 0.10) - if role in ['bass', 'sub_bass']: - shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.06) - if role in ['vocal', 'counter']: - shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.10 - if role in ['hat_open', 'top_loop', 'ride']: - shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 0.92 - elif profile_name == 'tech-house-deep': - # Deep minimal: subtle processing, wide stereo - if role in ['kick', 'sub_bass']: - shaped['volume'] *= 0.98 - if role in ['pad', 'drone', 'atmos', 'chords']: - shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 1.12 - if role in ['perc', 'top_loop']: - shaped['volume'] *= 0.95 - shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 0.88 - elif profile_name == 'tech-house-funky': - # Funky groove: wider pan, more echo, bouncy feel - if role in ['perc', 'top_loop', 'ride']: - shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.18 - if role in ['bass', 'sub_bass']: - shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.05) - if role in ['vocal', 'pluck', 'arp']: - shaped['sends']['space'] = shaped['sends'].get('space', 0.0) * 1.08 - if role in ['clap', 'hat_closed']: - shaped['volume'] *= 1.02 - - if 'latin' in style_text and role in ['perc', 'top_loop', 'ride', 'vocal']: - shaped['sends']['echo'] = shaped['sends'].get('echo', 0.0) * 1.12 - shaped['pan'] = max(-1.0, min(1.0, shaped['pan'] * 1.08)) - if 'industrial' in style_text and role in ['kick', 'bass', 'stab', 'impact', 'riser']: - shaped['sends']['heat'] = max(shaped['sends'].get('heat', 0.0), 0.09) - - shaped['volume'] = round(max(0.0, min(1.0, shaped['volume'])), 3) - shaped['pan'] = round(max(-1.0, min(1.0, shaped['pan'])), 3) - shaped['sends'] = { - send_key: round(max(0.0, min(1.0, float(send_value))), 3) - for send_key, send_value in shaped['sends'].items() - } - return shaped - - def _shape_role_fx_chain(self, role: str, profile: Dict[str, Any], style: str) -> List[Dict[str, Any]]: - chain = [dict(item) for item in ROLE_FX_CHAINS.get(role, [])] - profile_name = str(profile.get('name', 'default')).lower() - style_text = str(style or '').lower() - - if profile_name == 'warehouse': - if role in ['kick', 'bass', 'stab']: - chain.append({'device': 'Compressor', 'parameters': {'Threshold': -18.0}}) - if role in ['atmos', 'drone', 'pad']: - chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 7600.0, 'Dry/Wet': 0.14}}) - elif profile_name == 'festival': - if role in ['lead', 'arp', 'vocal']: - chain.append({'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.1}}) - if role in ['chords', 'pad']: - chain.append({'device': 'Utility', 'parameters': {'Width': 140.0}}) - elif profile_name == 'swing': - if role in ['perc', 'top_loop', 'ride', 'vocal']: - chain.append({'device': 'Echo', 'parameters': {'Dry/Wet': 0.08}}) - elif profile_name == 'jackin': - if role in ['clap', 'perc', 'vocal', 'counter']: - chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.5}}) - elif profile_name == 'tech-house-club': - # Club: punchy drums, saturated bass, crisp tops - if role in ['kick', 'clap']: - chain.append({'device': 'Compressor', 'parameters': {'Threshold': -16.0, 'Attack': 0.02}}) - if role in ['bass', 'sub_bass']: - chain.append({'device': 'Saturator', 'parameters': {'Drive': 2.0}}) - if role in ['hat_closed', 'hat_open', 'top_loop']: - chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 12000.0, 'Dry/Wet': 0.12}}) - if role in ['vocal', 'counter']: - chain.append({'device': 'Echo', 'parameters': {'Dry/Wet': 0.08}}) - elif profile_name == 'tech-house-deep': - # Deep: subtle saturation, atmospheric processing - if role in ['kick', 'bass']: - chain.append({'device': 'Compressor', 'parameters': {'Threshold': -20.0}}) - if role in ['pad', 'drone', 'atmos']: - chain.append({'device': 'Hybrid Reverb', 'parameters': {'Dry/Wet': 0.12}}) - if role in ['chords', 'pluck']: - chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 9200.0, 'Dry/Wet': 0.08}}) - elif profile_name == 'tech-house-funky': - # Funky: groove-enhancing FX, modulation - if role in ['perc', 'top_loop', 'ride']: - chain.append({'device': 'Echo', 'parameters': {'Dry/Wet': 0.10, 'Ping Pong': 0.3}}) - if role in ['bass', 'sub_bass']: - chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.8}}) - if role in ['vocal', 'pluck', 'arp']: - chain.append({'device': 'Chorus-Ensemble', 'parameters': {'Dry/Wet': 0.06}}) - if role in ['clap', 'hat_closed']: - chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.2}}) - - if 'industrial' in style_text and role in ['kick', 'bass', 'impact', 'riser']: - chain.append({'device': 'Saturator', 'parameters': {'Drive': 1.8}}) - if 'latin' in style_text and role in ['perc', 'top_loop', 'ride', 'vocal']: - chain.append({'device': 'Auto Filter', 'parameters': {'Frequency': 11200.0, 'Dry/Wet': 0.1}}) - - return chain - - def _get_section_drum_variant(self, role: str, section: Dict[str, Any]) -> str: - """Get appropriate drum variant for section and role with cross-generation diversity.""" - kind = str(section.get('kind', 'drop')).lower() - role_lower = role.lower() - - if role_lower not in DRUM_SECTION_VARIANTS.get(kind, {}): - return 'straight' - - variants = list(DRUM_SECTION_VARIANTS[kind][role_lower]) - valid_variants = [v for v in variants if v in DRUM_PATTERN_BANKS.get(role_lower, {})] - if not valid_variants and role_lower in DRUM_PATTERN_BANKS: - valid_variants = list(DRUM_PATTERN_BANKS[role_lower].keys()) - - if not valid_variants: - return 'straight' - - rng = self._section_rng(section, role, salt=1) - - if len(valid_variants) > 1: - scored_variants = [] - for v in valid_variants: - penalty = _get_pattern_variant_penalty('drum', f'{role_lower}_{v}') - score = rng.random() - penalty - scored_variants.append((score, v)) - scored_variants.sort(reverse=True) - chosen = scored_variants[0][1] - else: - chosen = valid_variants[0] - - _record_pattern_variant_usage('drum', f'{role_lower}_{chosen}') - return chosen - - def _generate_drum_pattern_from_bank(self, role: str, variant: str, - section_length: float, - velocity_base: int = 100) -> List[Dict[str, Any]]: - """Generate drum pattern from pattern bank.""" - role_lower = role.lower() - - if role_lower not in DRUM_PATTERN_BANKS: - return [] - - bank = DRUM_PATTERN_BANKS[role_lower] - if variant not in bank: - variant = list(bank.keys())[0] # Fallback to first - - positions = bank[variant] - notes = [] - - # Determine pitch based on role - pitch_map = { - 'kick': 36, 'clap': 39, 'hat_closed': 42, - 'hat_open': 46, 'perc': 50, 'ride': 51 - } - pitch = pitch_map.get(role_lower, 36) - - for pos in positions: - # Repeat pattern for each bar - for bar in range(int(section_length // 4)): - start = pos + (bar * 4.0) - if start < section_length: - # Add slight velocity variation - velocity = max(60, min(127, velocity_base + random.randint(-10, 10))) - duration = 0.1 if role_lower in ['hat_closed', 'hat_open', 'ride'] else 0.15 - notes.append(self._make_note(pitch, start, duration, velocity)) - - logger.debug(f"Generated drum pattern from bank: role={role}, variant={variant}, notes={len(notes)}") - return notes - - def _get_section_bass_variant(self, section: Dict[str, Any]) -> str: - """Get appropriate bass variant for section with cross-generation diversity.""" - kind = str(section.get('kind', 'drop')).lower() - - if kind not in BASS_SECTION_VARIANTS: - return 'anchor' - - variants = list(BASS_SECTION_VARIANTS[kind]) - valid_variants = [v for v in variants if v in BASS_PATTERN_BANKS] - if not valid_variants: - valid_variants = list(BASS_PATTERN_BANKS.keys()) - - rng = self._section_rng(section, 'bass', salt=2) - - if len(valid_variants) > 1: - scored_variants = [] - for v in valid_variants: - penalty = _get_pattern_variant_penalty('bass', v) - score = rng.random() - penalty - scored_variants.append((score, v)) - scored_variants.sort(reverse=True) - chosen = scored_variants[0][1] - else: - chosen = valid_variants[0] if valid_variants else 'anchor' - - _record_pattern_variant_usage('bass', chosen) - return chosen - - def _compute_section_signature(self, section: Dict[str, Any]) -> str: - """Compute a signature for section to detect repetition.""" - section = self._ensure_section_pattern_variants(section) - signature_parts = [] - drum_role_variants = dict(section.get('drum_role_variants') or {}) - - signature_parts.append(f"kick:{drum_role_variants.get('kick', section.get('drum_variant', 'default'))}") - signature_parts.append(f"clap:{drum_role_variants.get('clap', section.get('drum_variant', 'default'))}") - signature_parts.append(f"hat:{drum_role_variants.get('hat_closed', section.get('drum_variant', 'default'))}") - signature_parts.append(f"bass:{section.get('bass_bank_variant', section.get('bass_variant', 'default'))}") - signature_parts.append(f"lead:{section.get('melodic_bank_variant', section.get('melodic_variant', 'default'))}") - signature_parts.append(f"fill:{section.get('transition_fill', 'none')}") - - # Add density and swing - density = section.get('density', 1.0) - swing = section.get('swing', 0.0) - signature_parts.append(f"d:{density:.1f}") - signature_parts.append(f"s:{swing:.2f}") - - return "|".join(signature_parts) - - def _check_section_repetition(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Check and warn about excessive section repetition.""" - signatures = [] - consecutive_same = 0 - max_consecutive = 2 - - for i, section in enumerate(sections): - self._ensure_section_pattern_variants(section) - sig = self._compute_section_signature(section) - - if signatures and signatures[-1] == sig: - consecutive_same += 1 - if consecutive_same >= max_consecutive: - logger.warning("REPETITION: %d consecutive sections with same signature: %s", - consecutive_same + 1, sig) - self._force_section_pattern_variation(section) - sig = self._compute_section_signature(section) - else: - consecutive_same = 0 - - signatures.append(sig) - - return sections - - def _record_section_variant(self, section: Dict[str, Any], role: str, variant: str): - """Record variant used for a role in a section.""" - key = f'{role}_variant' - section[key] = variant - - def _choose_alternate_variant(self, options: List[str], current: Optional[str], rng: random.Random) -> Optional[str]: - ordered: List[str] = [] - for option in options: - if option not in ordered: - ordered.append(option) - if not ordered: - return current - alternatives = [option for option in ordered if option != current] - if not alternatives: - return current or ordered[0] - return rng.choice(alternatives) - - def _ensure_section_pattern_variants(self, section: Dict[str, Any]) -> Dict[str, Any]: - _kind = str(section.get('kind', 'drop')).lower() # noqa: F841 - used by helper methods via section dict - drum_role_variants = dict(section.get('drum_role_variants') or {}) - for role in ['kick', 'clap', 'hat_closed', 'hat_open', 'perc', 'ride']: - if role in drum_role_variants: - continue - variant = self._get_section_drum_variant(role, section) - if variant in DRUM_PATTERN_BANKS.get(role, {}): - drum_role_variants[role] = variant - self._record_section_variant(section, role, variant) - section['drum_role_variants'] = drum_role_variants - - bass_bank_variant = str(section.get('bass_bank_variant', '') or '') - if bass_bank_variant not in BASS_PATTERN_BANKS: - bass_bank_variant = self._get_section_bass_variant(section) - section['bass_bank_variant'] = bass_bank_variant - self._record_section_variant(section, 'bass_bank', str(section.get('bass_bank_variant', 'anchor'))) - - melodic_bank_variant = str(section.get('melodic_bank_variant', '') or '') - if melodic_bank_variant not in MELODIC_PATTERN_BANKS: - melodic_bank_variant = self._get_section_melodic_variant(section) - section['melodic_bank_variant'] = melodic_bank_variant - self._record_section_variant(section, 'melodic_bank', str(section.get('melodic_bank_variant', 'motif'))) - section.setdefault('pattern_variant_ready', True) - return section - - def _force_section_pattern_variation(self, section: Dict[str, Any]) -> Dict[str, Any]: - kind = str(section.get('kind', 'drop')).lower() - self._ensure_section_pattern_variants(section) - drum_role_variants = dict(section.get('drum_role_variants') or {}) - - for role in ['kick', 'clap', 'hat_closed']: - options = DRUM_SECTION_VARIANTS.get(kind, {}).get(role, []) - current = drum_role_variants.get(role) - next_variant = self._choose_alternate_variant(options, current, self._section_rng(section, role, salt=101)) - if next_variant: - drum_role_variants[role] = next_variant - self._record_section_variant(section, role, next_variant) - section['drum_role_variants'] = drum_role_variants - - bass_options = BASS_SECTION_VARIANTS.get(kind, []) - bass_variant = self._choose_alternate_variant( - bass_options, - str(section.get('bass_bank_variant', '') or ''), - self._section_rng(section, 'bass', salt=102), - ) - if bass_variant: - section['bass_bank_variant'] = bass_variant - self._record_section_variant(section, 'bass_bank', bass_variant) - - melodic_options = MELODIC_SECTION_VARIANTS.get(kind, []) - melodic_variant = self._choose_alternate_variant( - melodic_options, - str(section.get('melodic_bank_variant', '') or ''), - self._section_rng(section, 'melodic', salt=103), - ) - if melodic_variant: - section['melodic_bank_variant'] = melodic_variant - self._record_section_variant(section, 'melodic_bank', melodic_variant) - - return section - - def _generate_bass_pattern_from_bank(self, variant: str, key: str, - section_length: float, - velocity_base: int = 95) -> List[Dict[str, Any]]: - """Generate bass pattern from pattern bank.""" - if variant not in BASS_PATTERN_BANKS: - variant = 'anchor' - - bank = BASS_PATTERN_BANKS[variant] - positions = bank['positions'] - durations = bank['durations'] - style = bank.get('style', 'root') - - root_note = key[:-1] if len(key) > 1 else key - root_midi = self.note_name_to_midi(root_note, 2) - - notes = [] - for bar in range(int(section_length // 4)): - for i, pos in enumerate(positions): - start = pos + (bar * 4.0) - if start < section_length: - duration = durations[i] if i < len(durations) else 0.4 - velocity = max(70, min(120, velocity_base + random.randint(-8, 8))) - - # Adjust pitch based on style - pitch = root_midi - if style == 'ascending' and bar > 0: - pitch += min(bar, 5) # Rise over bars - elif style == 'syncopated' and i % 2 == 1: - pitch += 5 # Fifth on offbeats - - notes.append(self._make_note(pitch, start, duration, velocity)) - - logger.debug(f"Generated bass pattern from bank: variant={variant}, notes={len(notes)}") - return notes - - def _vary_drum_notes(self, notes: List[Dict[str, Any]], role: str, section: Dict[str, Any], - section_length: float) -> List[Dict[str, Any]]: - section = self._ensure_section_pattern_variants(section) - role_variant = str((section.get('drum_role_variants') or {}).get(role, '') or '').lower() - kind = str(section.get('kind', 'drop')).lower() - density = float(section.get('density', 1.0)) - _ = int(section.get('energy', 1)) - variant = str(section.get('drum_variant', 'straight')).lower() - swing = float(section.get('swing', 0.0)) - tightness = float(self._current_generation_profile.get('drum_tightness', 1.0)) - rng = self._section_rng(section, role, salt=5) - - if role_variant in DRUM_PATTERN_BANKS.get(role, {}): - logger.debug(f"Using section pattern bank for {role} with variant {role_variant} in section {kind}") - bank_notes = self._generate_drum_pattern_from_bank(role, role_variant, section_length) - if bank_notes: - use_bank_prob = 0.85 if kind in ['intro', 'break', 'outro'] else 0.95 - if rng.random() < use_bank_prob or not notes: - return bank_notes - - if not notes: - if role in DRUM_PATTERN_BANKS: - all_variants = list(DRUM_PATTERN_BANKS[role].keys()) - if all_variants: - fallback_variant = rng.choice(all_variants) - return self._generate_drum_pattern_from_bank(role, fallback_variant, section_length) - return [] - - varied = list(notes) - - if variant == 'skip' and role in ['hat_closed', 'hat_open', 'top_loop', 'perc', 'ride']: - varied = self._apply_density_mask(varied, section, role, keep_probability=min(0.94, max(0.54, density - 0.08))) - elif variant == 'pressure' and role in ['kick', 'hat_closed', 'perc']: - pressure_notes = [] - for bar_start in range(0, int(section_length), 4): - if role == 'kick' and rng.random() > 0.35: - pressure_notes.append(self._make_note(36, min(section_length - 0.05, bar_start + 3.5), 0.12, 92)) - elif role == 'hat_closed' and rng.random() > 0.45: - pressure_notes.append(self._make_note(42, min(section_length - 0.05, bar_start + 3.75), 0.06, 58)) - elif role == 'perc' and rng.random() > 0.5: - pressure_notes.append(self._make_note(50, min(section_length - 0.05, bar_start + 3.25), 0.12, 74)) - varied = self._merge_section_notes(varied, pressure_notes, section_length) - elif variant == 'shuffle' and role not in ['kick', 'clap', 'sc_trigger', 'crash']: - varied = self._apply_swing(varied, swing or (0.035 / max(0.8, tightness)), section_length) - - if swing > 0.0 and role in ['top_loop', 'perc', 'ride']: - varied = self._apply_swing(varied, swing * 0.55, section_length) - - return varied - - def _vary_bass_notes(self, notes: List[Dict[str, Any]], role: str, key: str, - section: Dict[str, Any], section_length: float) -> List[Dict[str, Any]]: - section = self._ensure_section_pattern_variants(section) - bank_variant = str(section.get('bass_bank_variant', '') or '').lower() - kind = str(section.get('kind', 'drop')).lower() - variant = str(section.get('bass_variant', 'anchor')).lower() - - if bank_variant in BASS_PATTERN_BANKS: - logger.debug(f"Using section bass pattern bank for variant {bank_variant} in section {kind}") - return self._generate_bass_pattern_from_bank(bank_variant, key, section_length) - - if not notes: - if bank_variant in BASS_PATTERN_BANKS: - return self._generate_bass_pattern_from_bank(bank_variant, key, section_length) - all_variants = list(BASS_PATTERN_BANKS.keys()) - if all_variants: - rng = self._section_rng(section, role, salt=7) - fallback = rng.choice(all_variants) - return self._generate_bass_pattern_from_bank(fallback, key, section_length) - return [] - - profile_motion = str(self._current_generation_profile.get('bass_motion', 'locked')).lower() - rng = self._section_rng(section, role, salt=7) - root_note = key[:-1] if len(key) > 1 else key - scale_name = 'minor' if 'm' in key.lower() else 'major' - root_midi = self.note_name_to_midi(root_note, 2) - scale_notes = self.get_scale_notes(root_midi, scale_name) - - varied = [] - for index, note in enumerate(notes): - pitch = int(note['pitch']) - start = float(note['start']) - duration = float(note['duration']) - velocity = int(note['velocity']) - - if variant == 'anchor' and (start % 4.0) < 0.001: - pitch = root_midi - duration = max(duration, 0.5) - elif variant == 'bounce' and (start % 1.0) >= 0.5: - velocity = min(124, velocity + 8) - duration = max(0.18, duration * 0.82) - elif variant == 'syncopated' and (start % 1.0) < 0.001 and rng.random() > 0.4: - start = min(section_length - 0.05, start + 0.25) - duration = max(0.16, duration * 0.68) - elif variant == 'pedal' and index % 3 == 0: - pitch = root_midi - - if profile_motion == 'lifted' and index % 8 == 6: - pitch += 12 - elif profile_motion == 'syncopated' and rng.random() > 0.72: - pitch = scale_notes[(index + 4) % len(scale_notes)] - elif profile_motion == 'bouncy' and (start % 4.0) >= 2.0: - velocity = min(124, velocity + 5) - - varied.append(self._make_note(pitch, start, duration, velocity)) - - return self._shape_notes_for_section(varied, kind, role, section_length) - - def _get_section_melodic_variant(self, section: Dict[str, Any]) -> str: - """Get appropriate melodic variant for section with cross-generation diversity.""" - kind = str(section.get('kind', 'drop')).lower() - - if kind not in MELODIC_SECTION_VARIANTS: - return 'motif' - - variants = list(MELODIC_SECTION_VARIANTS[kind]) - valid_variants = [v for v in variants if v in MELODIC_PATTERN_BANKS] - if not valid_variants: - valid_variants = list(MELODIC_PATTERN_BANKS.keys()) - - rng = self._section_rng(section, 'melodic', salt=3) - - if len(valid_variants) > 1: - scored_variants = [] - for v in valid_variants: - penalty = _get_pattern_variant_penalty('melodic', v) - score = rng.random() - penalty - scored_variants.append((score, v)) - scored_variants.sort(reverse=True) - chosen = scored_variants[0][1] - else: - chosen = valid_variants[0] if valid_variants else 'motif' - - _record_pattern_variant_usage('melodic', chosen) - return chosen - - def _generate_melodic_pattern_from_bank(self, variant: str, key: str, - scale_name: str, - section_length: float, - velocity_base: int = 90) -> List[Dict[str, Any]]: - """Generate melodic pattern from pattern bank.""" - if variant not in MELODIC_PATTERN_BANKS: - variant = 'motif' - - bank = MELODIC_PATTERN_BANKS[variant] - intervals = bank['intervals'] - rhythm = bank['rhythm'] - durations = bank['durations'] - - root_note = key[:-1] if len(key) > 1 else key - root_midi = self.note_name_to_midi(root_note, 5) - scale_notes = self.get_scale_notes(root_midi, scale_name) - - notes = [] - for bar in range(int(section_length // 4)): - for i, pos in enumerate(rhythm): - start = pos + (bar * 4.0) - if start < section_length: - interval = intervals[i] if i < len(intervals) else intervals[-1] - pitch = scale_notes[interval % len(scale_notes)] - duration = durations[i] if i < len(durations) else 0.3 - velocity = max(60, min(110, velocity_base + random.randint(-10, 10))) - - notes.append(self._make_note(pitch, start, duration, velocity)) - - logger.debug(f"Generated melodic pattern from bank: variant={variant}, notes={len(notes)}") - return notes - - def _vary_melodic_notes(self, notes: List[Dict[str, Any]], role: str, key: str, scale_name: str, - section: Dict[str, Any], section_length: float) -> List[Dict[str, Any]]: - section = self._ensure_section_pattern_variants(section) - bank_variant = str(section.get('melodic_bank_variant', '') or '').lower() - kind = str(section.get('kind', 'drop')).lower() - - if bank_variant in MELODIC_PATTERN_BANKS: - logger.debug(f"Using section melodic pattern bank for variant {bank_variant} in section {kind}") - return self._generate_melodic_pattern_from_bank(bank_variant, key, scale_name, section_length) - - if not notes: - if bank_variant in MELODIC_PATTERN_BANKS: - return self._generate_melodic_pattern_from_bank(bank_variant, key, scale_name, section_length) - all_variants = list(MELODIC_PATTERN_BANKS.keys()) - if all_variants: - rng = self._section_rng(section, role, salt=11) - fallback = rng.choice(all_variants) - return self._generate_melodic_pattern_from_bank(fallback, key, scale_name, section_length) - return [] - - variant = str(section.get('melodic_variant', 'motif')).lower() - profile_motion = str(self._current_generation_profile.get('melodic_motion', 'restrained')).lower() - rng = self._section_rng(section, role, salt=11) - root_note = key[:-1] if len(key) > 1 else key - root_midi = self.note_name_to_midi(root_note, 5) - scale_notes = self.get_scale_notes(root_midi, scale_name) - - transformed = [] - for index, note in enumerate(notes): - start = float(note['start']) - pitch = int(note['pitch']) - duration = float(note['duration']) - velocity = int(note['velocity']) - keep = True - - if variant == 'response' and int(start / 2.0) % 2 == 0 and role in ['lead', 'pluck', 'counter']: - keep = False - elif variant == 'lift' and index % 4 == 3: - pitch += 12 - velocity = min(124, velocity + 10) - elif variant == 'descend' and index % 5 == 4: - pitch -= 12 - duration = max(0.16, duration * 0.9) - elif variant == 'drone': - keep = (start % 4.0) < 0.001 or duration >= 0.5 - if keep: - pitch = scale_notes[index % min(3, len(scale_notes))] - duration = max(duration, 1.2) - - if keep and profile_motion in ['anthemic', 'hooky'] and role in ['lead', 'arp', 'pluck']: - if rng.random() > 0.78: - pitch += 12 - elif profile_motion == 'hooky' and rng.random() > 0.84: - start = min(section_length - 0.05, start + 0.25) - - if keep and profile_motion == 'call_response' and role in ['counter', 'pluck'] and (start % 4.0) < 2.0: - velocity = max(52, velocity - 8) - - if keep: - transformed.append(self._make_note(pitch, start, duration, velocity)) - - if role in ['arp', 'pluck'] and float(section.get('swing', 0.0)) > 0.0: - transformed = self._apply_swing(transformed, float(section.get('swing', 0.0)) * 0.45, section_length) - - return self._shape_notes_for_section(transformed, kind, role, section_length) - - def _transpose_notes(self, notes: List[Dict[str, Any]], semitones: int) -> List[Dict[str, Any]]: - return [ - self._make_note(note['pitch'] + semitones, note['start'], note['duration'], note['velocity']) - for note in notes - ] - - def _scale_note_lengths(self, notes: List[Dict[str, Any]], factor: float, minimum: float = 0.1) -> List[Dict[str, Any]]: - scaled = [] - for note in notes: - scaled.append( - self._make_note( - note['pitch'], - note['start'], - max(minimum, float(note['duration']) * factor), - note['velocity'], - ) - ) - return scaled - - def _shape_notes_for_section(self, notes: List[Dict[str, Any]], section_kind: str, role: str, - section_length: float) -> List[Dict[str, Any]]: - if not notes: - return [] - - shaped = [] - for note in notes: - start = float(note['start']) - keep = True - - if section_kind in ['intro', 'outro'] and role in ['bass', 'sub_bass', 'lead', 'pluck', 'arp', 'counter']: - keep = int(start * 2) % 4 == 0 - elif section_kind == 'break' and role in ['bass', 'sub_bass', 'lead', 'pluck', 'arp', 'counter', 'clap', 'hat_open', 'ride']: - keep = int(start) % 4 == 0 - - if keep and start < section_length: - duration = min(float(note['duration']), section_length - start) - shaped.append(self._make_note(note['pitch'], start, duration, note['velocity'])) - return shaped - - def _merge_section_notes(self, base_notes: List[Dict[str, Any]], extra_notes: List[Dict[str, Any]], - section_length: float) -> List[Dict[str, Any]]: - merged = [] - for note in list(base_notes) + list(extra_notes): - start = float(note['start']) - if start >= section_length: - continue - duration = min(float(note['duration']), max(0.05, section_length - start)) - merged.append(self._make_note(note['pitch'], start, duration, note['velocity'])) - merged.sort(key=lambda item: (item['start'], item['pitch'])) - return merged - - def _build_drum_fill(self, role: str, section_length: float, intensity: int) -> List[Dict[str, Any]]: - fill_start = max(0.0, section_length - 1.0) - if role == 'kick' and intensity >= 3: - return [self._make_note(36, fill_start + step, 0.14, 112 + (idx % 2) * 8) for idx, step in enumerate([0.0, 0.25, 0.5, 0.75])] - if role == 'clap' and intensity >= 3: - return [self._make_note(39, fill_start + step, 0.18, 92 + idx * 6) for idx, step in enumerate([0.25, 0.5, 0.75])] - if role == 'hat_closed': - return [self._make_note(42, fill_start + (idx * 0.125), 0.06, 64 + (idx % 4) * 6) for idx in range(8)] - if role == 'perc' and intensity >= 2: - return [ - self._make_note(37, fill_start + 0.125, 0.08, 72), - self._make_note(47, fill_start + 0.375, 0.08, 76), - self._make_note(50, fill_start + 0.625, 0.1, 82), - ] - return [] - - def _build_turnaround_notes(self, key: str, scale_name: str, section_length: float, - octave: int, velocity: int = 92) -> List[Dict[str, Any]]: - root_note = key[:-1] if len(key) > 1 else key - root_midi = self.note_name_to_midi(root_note, octave) - scale_notes = self.get_scale_notes(root_midi, scale_name) - fill_start = max(0.0, section_length - 2.0) - degrees = [0, 2, 4, 6] - notes = [] - for index, degree in enumerate(degrees): - pitch = scale_notes[degree % len(scale_notes)] - notes.append(self._make_note(pitch, fill_start + (index * 0.5), 0.38, velocity + index * 4)) - return notes - - def _generate_fill_pattern(self, fill_name: str, start_offset: float) -> Tuple[List[Dict[str, Any]], List[str]]: - """ - Generate fill pattern at specified offset. - - Returns: - (notes, roles) - tuple of note list and list of roles used - """ - if fill_name not in FILL_PATTERNS: - return [], [] - - fill = FILL_PATTERNS[fill_name] - notes = [] - roles_used = [] - - pitch_map = { - 'kick': 36, 'snare': 38, 'hat': 42, 'hat_open': 46, - 'crash': 49, 'ride': 51, 'perc': 50 - } - - for role, positions in fill['pattern'].items(): - roles_used.append(role) - pitch = pitch_map.get(role, 50) - velocity = fill['velocities'].get(role, 90) - - for pos in positions: - start = start_offset + pos - duration = 0.1 if role in ['hat', 'hat_open', 'ride'] else 0.15 - notes.append(self._make_note(pitch, start, duration, velocity)) - - # Track materialization for debugging/logging - if not hasattr(self, '_transition_materialization_log'): - self._transition_materialization_log = [] - self._transition_materialization_log.append({ - 'fill': fill_name, - 'start': start_offset, - 'notes_count': len(notes), - 'roles': roles_used - }) - - return notes, roles_used - - def _generate_transition_events(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Generate fill and transition events between sections.""" - transition_events = [] - - # Calculate start positions for each section - arrangement_time = 0.0 - for section in sections: - section['start'] = arrangement_time - arrangement_time += float(section.get('beats', 0.0) or 0.0) - - for i, section in enumerate(sections): - kind = str(section.get('kind', '')).lower() - start = float(section.get('start', 0.0)) - length = float(section.get('beats', 8.0)) - end = start + length - - # Check for transition to next section - if i < len(sections) - 1: - next_kind = str(sections[i + 1].get('kind', '')).lower() - transition_key = (kind, next_kind) - - if transition_key in TRANSITION_EVENTS: - fills = TRANSITION_EVENTS[transition_key] - rng = self._section_rng(section, 'transition', salt=20) - fill_name = rng.choice(fills) - - # Get notes and roles from fill pattern - fill_notes, fill_roles = self._generate_fill_pattern(fill_name, end - 2.0) - - transition_events.append({ - 'fill': fill_name, - 'start': end - 2.0, - 'section_kind': kind, - 'next_section_kind': next_kind, - 'roles': fill_roles, - 'notes': fill_notes, # Include actual notes for materialization - 'notes_count': len(fill_notes) - }) - logger.debug("TRANSITION: Added '%s' at %.1f for %s->%s", - fill_name, end - 2.0, kind, next_kind) - - return transition_events - - def _apply_transition_density_rules(self, transition_events: List[Dict], - sections: List[Dict]) -> List[Dict]: - """ - Apply anti-overcrowding rules to transition events. - - Returns filtered list of events. - """ - if not transition_events: - return [] - - filtered = [] - last_event_time = {} # Track last time of each event type - section_fill_counts = defaultdict(int) # Track fills per section - - for event in transition_events: - fill_name = event.get('fill', '') - start = event.get('start', 0.0) - section_kind = event.get('section_kind', 'drop') - - # Rule 1: Max fills per section - max_fills = TRANSITION_DENSITY_RULES['max_fills_by_section'].get(section_kind, 2) - if section_fill_counts[section_kind] >= max_fills: - logger.debug("TRANSITION_DENSITY: Skipping '%s' - section '%s' at max (%d fills)", - fill_name, section_kind, max_fills) - continue - - # Rule 2: Minimum distance between same-type events - min_dist = TRANSITION_DENSITY_RULES['min_distance_same_type'].get(fill_name, 0) - if fill_name in last_event_time: - time_since_last = start - last_event_time[fill_name] - if time_since_last < min_dist: - logger.debug("TRANSITION_DENSITY: Skipping '%s' - too close to previous (%.1f < %.1f)", - fill_name, time_since_last, min_dist) - continue - - # Rule 3: Check for exclusive events at same position - skip = False - for existing in filtered: - if abs(existing.get('start', -999) - start) < 0.5: # Same position - for exclusive_set in TRANSITION_DENSITY_RULES['exclusive_events']: - if fill_name in exclusive_set and existing.get('fill') in exclusive_set: - logger.debug("TRANSITION_DENSITY: Skipping '%s' - exclusive with '%s' at %.1f", - fill_name, existing.get('fill'), start) - skip = True - break - if skip: - break - - if skip: - continue - - # Event passes all rules - filtered.append(event) - last_event_time[fill_name] = start - section_fill_counts[section_kind] += 1 - - logger.info("TRANSITION_DENSITY: %d events passed filtering (from %d original)", - len(filtered), len(transition_events)) - - return filtered - - def _transition_events_to_notes(self, transition_events: List[Dict]) -> List[Dict]: - """Convert filtered transition events to MIDI notes.""" - notes = [] - for event in transition_events: - fill_name = event.get('fill', '') - start = event.get('start', 0.0) - fill_notes, _ = self._generate_fill_pattern(fill_name, start) - notes.extend(fill_notes) - return notes - - def _materialize_transition_events(self, config: Dict[str, Any], - track_blueprints: List[Dict]) -> List[Dict]: - """ - Materialize transition events into track blueprints. - - Adds actual MIDI notes to transition-oriented tracks based on transition_events config. - """ - transition_events = config.get('transition_events', []) - if not transition_events: - config['transition_materialization'] = { - 'events_count': 0, - 'materialized': False, - 'note_count': 0, - 'track_roles': [], - } - return track_blueprints - - transition_track_targets = { - 'drum_fill_4bar': 'snare_fill', - 'drum_fill_2bar': 'snare_fill', - 'snare_roll': 'snare_fill', - 'hat_open_build': 'riser', - 'kick_drop': 'impact', - 'crash_impact': 'crash', - } - pitch_to_track_role = { - 36: 'kick', - 38: 'snare_fill', - 42: 'hat_closed', - 46: 'hat_open', - 49: 'crash', - 50: 'perc', - 51: 'ride', - } - - # Build a lookup dict of tracks by role - tracks_by_role = {} - for track in track_blueprints: - role = track.get('role', '') - if role: - tracks_by_role[role] = track - - # Track what was materialized - materialized_count = 0 - materialized_track_roles: set = set() - - # Materialize each transition event - for event in transition_events: - fill_name = event.get('fill', '') - fill_start = event.get('start', 0.0) - fill_notes = event.get('notes', []) - - if not fill_notes: - event['materialized'] = False - event['materialized_notes_count'] = 0 - event['materialized_track_roles'] = [] - continue - - preferred_track_role = transition_track_targets.get(fill_name) - preferred_note_map: Dict[str, List[Dict[str, Any]]] = {} - if preferred_track_role and preferred_track_role in tracks_by_role: - preferred_note_map[preferred_track_role] = list(fill_notes) - - fallback_note_map: Dict[str, List[Dict[str, Any]]] = {} - for note in fill_notes: - note_role = pitch_to_track_role.get(int(note.get('pitch', 0))) - if note_role: - fallback_note_map.setdefault(note_role, []).append(note) - - # Add notes to appropriate tracks - event_materialized_count = 0 - event_track_roles: set = set() - - for notes_by_track_role in [preferred_note_map, fallback_note_map]: - if not notes_by_track_role: - continue - - for track_role, notes_to_add in notes_by_track_role.items(): - if track_role not in tracks_by_role: - logger.debug("TRANSITION_MATERIALIZATION: No track for role '%s', skipping %d notes", - track_role, len(notes_to_add)) - continue - if track_role in event_track_roles: - continue - - track = tracks_by_role[track_role] - clips = track.get('clips', []) - - for clip in clips: - clip_scene_index = clip.get('scene_index', -1) - sections = config.get('sections', []) - if clip_scene_index < 0 or clip_scene_index >= len(sections): - continue - - section = sections[clip_scene_index] - section_start = float(section.get('start', 0.0)) - section_beats = float(section.get('beats', 0.0)) - - if section_start <= fill_start < section_start + section_beats: - existing_notes = clip.get('notes', []) - adjusted_notes = [] - for note in notes_to_add: - adjusted_note = dict(note) - adjusted_note['start'] = note['start'] - section_start - adjusted_notes.append(adjusted_note) - - existing_notes.extend(adjusted_notes) - existing_notes.sort(key=lambda item: (float(item.get('start', 0.0)), int(item.get('pitch', 0)))) - clip['notes'] = existing_notes - materialized_count += len(adjusted_notes) - event_materialized_count += len(adjusted_notes) - materialized_track_roles.add(track_role) - event_track_roles.add(track_role) - - logger.debug("TRANSITION_MATERIALIZATION: Added %d notes to track '%s' (role: %s) for fill '%s' at %.1f", - len(adjusted_notes), track.get('name', ''), track_role, fill_name, fill_start) - break - - if event_materialized_count > 0: - break - - event['materialized'] = event_materialized_count > 0 - event['materialized_notes_count'] = event_materialized_count - event['materialized_track_roles'] = sorted(event_track_roles) - - logger.info("TRANSITION_MATERIALIZATION: Total %d notes materialized across all tracks", materialized_count) - config['transition_materialization'] = { - 'events_count': len(transition_events), - 'materialized': materialized_count > 0, - 'note_count': materialized_count, - 'track_roles': sorted(materialized_track_roles), - } - return track_blueprints - - def _find_reference_track_profile(self) -> Optional[Dict[str, Any]]: - matches: List[Tuple[float, Dict[str, Any]]] = [] - audio_extensions = {'.wav', '.mp3', '.aiff', '.flac', '.aif', '.ogg'} - for directory in REFERENCE_SEARCH_DIRS: - if not directory.exists(): - continue - for candidate in sorted(directory.glob('*')): - if not candidate.is_file(): - continue - if candidate.suffix.lower() not in audio_extensions: - continue - normalized_name = candidate.name.lower() - for profile in REFERENCE_TRACK_PROFILES: - if all(term in normalized_name for term in profile.get('match_terms', [])): - resolved = dict(profile) - resolved['path'] = str(candidate) - resolved['file_name'] = candidate.name - try: - modified = float(candidate.stat().st_mtime) - except Exception: - modified = 0.0 - matches.append((modified, resolved)) - - if not matches: - return None - matches.sort(key=lambda item: item[0], reverse=True) - return matches[0][1] - - def _resolve_reference_track_profile(self, genre: str, style: str, bpm: float, - key: str, structure: str, - reference_energy_profile: Optional[List[Dict[str, Any]]] = None) -> Optional[Dict[str, Any]]: - profile = self._find_reference_track_profile() - if not profile: - return None - - target_genre = profile.get('genre', '') - compatible_genres = {target_genre, 'techno', 'tech-house', 'house'} - if genre and genre not in compatible_genres: - return None - - if bpm <= 0: - bpm = float(profile.get('bpm', bpm or 0)) - if not key: - key = profile.get('key', key) - if not style: - style = profile.get('style', style) - if not structure or structure == 'standard': - structure = profile.get('structure', structure or 'standard') - - result = { - 'genre': target_genre or genre, - 'style': style, - 'bpm': bpm, - 'key': key, - 'structure': structure, - 'reference': profile, - } - - # Forward energy profile if available - if reference_energy_profile: - result['reference_energy_profile'] = reference_energy_profile - - return result - - def _build_return_states(self, returns: List[Dict[str, Any]], section: Dict[str, Any]) -> List[Dict[str, Any]]: - if not returns: - return [] - - kind = str(section.get('kind', 'drop')).lower() - energy = max(1, int(section.get('energy', 1))) - profile_name = str(self._current_generation_profile.get('name', 'default')).lower() - style_text = str(self._current_generation_profile.get('style_text', '')).lower() - - volume_factors = { - 'space': {'intro': 0.94, 'build': 0.84, 'drop': 0.7, 'break': 1.02, 'outro': 0.86}, - 'echo': {'intro': 0.8, 'build': 1.04, 'drop': 0.72, 'break': 0.92, 'outro': 0.78}, - 'heat': {'intro': 0.56, 'build': 0.88, 'drop': 1.06, 'break': 0.42, 'outro': 0.66}, - 'glue': {'intro': 0.72, 'build': 0.86, 'drop': 1.02, 'break': 0.58, 'outro': 0.74}, - } - space_mix = {'intro': 0.94, 'build': 0.88, 'drop': 0.8, 'break': 1.0, 'outro': 0.9} - echo_mix = {'intro': 0.72, 'build': 0.92, 'drop': 0.62, 'break': 0.84, 'outro': 0.76} - width_targets = {'intro': 1.02, 'build': 1.08, 'drop': 1.12, 'break': 1.16, 'outro': 1.04} - filter_factors = {'intro': 0.86, 'build': 1.0, 'drop': 1.18, 'break': 0.78, 'outro': 0.9} - drive_offsets = {'intro': -1.2, 'build': 0.2, 'drop': 1.0, 'break': -1.6, 'outro': -0.5} - threshold_offsets = {'intro': 1.5, 'build': -0.5, 'drop': -2.0, 'break': 2.5, 'outro': 1.0} - - states = [] - for return_index, return_spec in enumerate(returns): - send_key = str(return_spec.get('send_key', return_spec.get('name', ''))).strip().lower() - if not send_key: - continue - - base_volume = float(return_spec.get('volume', 0.7)) - volume_factor = volume_factors.get(send_key, {}).get(kind, 1.0) - if send_key in ['heat', 'glue'] and energy >= 4: - volume_factor += 0.04 - elif send_key in ['space', 'echo'] and kind == 'break': - volume_factor += 0.04 - - if profile_name == 'warehouse' and send_key == 'heat': - volume_factor += 0.05 - elif profile_name == 'festival' and send_key == 'space': - volume_factor += 0.06 - elif profile_name == 'swing' and send_key == 'echo': - volume_factor += 0.05 - elif profile_name == 'jackin' and send_key == 'glue': - volume_factor += 0.05 - - if 'industrial' in style_text and send_key == 'heat': - volume_factor += 0.05 - if 'latin' in style_text and send_key == 'echo': - volume_factor += 0.06 - - state = { - 'return_index': return_index, - 'send_key': send_key, - 'volume': self._clamp_unit(base_volume * volume_factor), - 'device_parameters': [], - } - - for device_index, device_spec in enumerate(return_spec.get('device_chain', []) or []): - if not isinstance(device_spec, dict): - continue - device_name = str(device_spec.get('device', '') or '').strip() - if not device_name: - continue - device_name_lower = device_name.lower() - base_parameters = dict(device_spec.get('parameters', {})) - parameter_updates = {} - - if send_key == 'space': - if 'hybrid reverb' in device_name_lower: - parameter_updates['Dry/Wet'] = space_mix.get(kind, 0.9) - elif 'auto filter' in device_name_lower: - base_frequency = float(base_parameters.get('Frequency', 8200.0) or 8200.0) - parameter_updates['Frequency'] = round(base_frequency * filter_factors.get(kind, 1.0), 3) - parameter_updates['Dry/Wet'] = {'intro': 0.18, 'build': 0.22, 'drop': 0.08, 'break': 0.28, 'outro': 0.14}.get(kind, 0.16) - elif 'utility' in device_name_lower: - parameter_updates['Stereo Width'] = width_targets.get(kind, 1.08) - elif send_key == 'echo': - if 'echo' in device_name_lower: - parameter_updates['Dry/Wet'] = echo_mix.get(kind, 0.78) - elif 'auto filter' in device_name_lower: - base_frequency = float(base_parameters.get('Frequency', 9800.0) or 9800.0) - parameter_updates['Frequency'] = round(base_frequency * {'intro': 0.94, 'build': 1.08, 'drop': 0.88, 'break': 0.9, 'outro': 0.92}.get(kind, 1.0), 3) - parameter_updates['Dry/Wet'] = {'intro': 0.08, 'build': 0.14, 'drop': 0.06, 'break': 0.16, 'outro': 0.09}.get(kind, 0.1) - elif 'hybrid reverb' in device_name_lower: - parameter_updates['Dry/Wet'] = {'intro': 0.12, 'build': 0.18, 'drop': 0.08, 'break': 0.22, 'outro': 0.1}.get(kind, 0.12) - elif send_key == 'heat': - if 'saturator' in device_name_lower: - base_drive = float(base_parameters.get('Drive', 4.5) or 4.5) - parameter_updates['Drive'] = round(max(0.5, base_drive + drive_offsets.get(kind, 0.0)), 3) - elif 'compressor' in device_name_lower: - base_threshold = float(base_parameters.get('Threshold', -16.0) or -16.0) - parameter_updates['Threshold'] = round(base_threshold + threshold_offsets.get(kind, 0.0), 3) - elif send_key == 'glue': - if 'compressor' in device_name_lower: - base_threshold = float(base_parameters.get('Threshold', -18.0) or -18.0) - parameter_updates['Threshold'] = round(base_threshold + {'intro': 1.0, 'build': -0.6, 'drop': -1.4, 'break': 1.8, 'outro': 0.8}.get(kind, 0.0), 3) - elif 'limiter' in device_name_lower: - parameter_updates['Gain'] = {'intro': -0.4, 'build': 0.0, 'drop': 0.35, 'break': -0.6, 'outro': -0.3}.get(kind, 0.0) - - for parameter_name, value in parameter_updates.items(): - state['device_parameters'].append({ - 'device_index': int(device_index), - 'device_name': device_name, - 'parameter': parameter_name, - 'value': value, - }) - - states.append(state) - - return states - -# ========================================================================= - # SECTION AUTOMATION METHODS - # ========================================================================= - - def _generate_automation_envelope( - self, - parameter_start: float, - parameter_end: float, - section_length: float, - curve_name: str = 'linear', - num_points: int = 8 - ) -> List[Dict[str, Any]]: - """ - Generate automation envelope points for a parameter over a section. - - Args: - parameter_start: Starting value of the parameter - parameter_end: Ending value of the parameter - section_length: Length of the section in beats - curve_name: Name of the envelope curve to use - num_points: Number of envelope points to generate - - Returns: - List of automation points with time and value - """ - curve_func = ENVELOPE_CURVES.get(curve_name, ENVELOPE_CURVES['linear']) - envelope_points = [] - - for i in range(num_points): - position = i / (num_points - 1) if num_points > 1 else 0.0 - curved_position = curve_func(position) - value = parameter_start + (parameter_end - parameter_start) * curved_position - time = section_length * position - - envelope_points.append({ - 'time': round(time, 3), - 'value': round(value, 4), - 'curve_position': round(position, 3), - }) - - return envelope_points - - def _build_section_automation( - self, - section: Dict[str, Any], - buses: List[Dict[str, Any]], - returns: List[Dict[str, Any]] - ) -> Dict[str, Any]: - """ - Build automation data for a single section. - - Args: - section: Section configuration dictionary - buses: List of bus track configurations - returns: List of return track configurations - - Returns: - Dictionary containing automation data for the section - """ - kind = str(section.get('kind', 'drop')).lower() - section_length = float(section.get('beats', 32.0)) - energy = float(section.get('energy', 1)) - - # Get base automation template for this section kind - base_automation = SECTION_AUTOMATION.get(kind, SECTION_AUTOMATION.get('drop', {})) - - # Determine envelope curve - curve_name = base_automation.get('envelope_curve', 'linear') - - # Apply energy scaling - energy_factor = max(0.5, min(1.5, energy / 3.0)) - - automation_data = { - 'section_index': int(section.get('index', 0)), - 'section_name': section.get('name', 'SECTION'), - 'section_kind': kind, - 'section_length': section_length, - 'energy': round(base_automation.get('energy', 0.5) * energy_factor, 3), - 'bus_automation': [], - 'return_automation': [], - 'master_automation': {}, - } - - # Build bus automation - for bus in buses: - bus_key = str(bus.get('key', '')).lower() - if not bus_key: - continue - - bus_filter_settings = base_automation.get('filters', {}).get(bus_key, {}) - if not bus_filter_settings: - continue - - bus_auto = { - 'bus_key': bus_key, - 'bus_name': bus.get('name', bus_key.upper()), - 'parameters': [] - } - - # Filter frequency automation - if 'frequency' in bus_filter_settings: - freq_start = bus_filter_settings['frequency'] * (1.1 - energy_factor * 0.2) - freq_end = bus_filter_settings['frequency'] * energy_factor - bus_auto['parameters'].append({ - 'device': 'Auto Filter', - 'parameter': 'Frequency', - 'envelope': self._generate_automation_envelope( - freq_start, freq_end, section_length, curve_name - ), - 'start_value': round(freq_start, 1), - 'end_value': round(freq_end, 1), - }) - - # Filter resonance automation - if 'resonance' in bus_filter_settings: - res_start = bus_filter_settings['resonance'] * 0.8 - res_end = bus_filter_settings['resonance'] * energy_factor - bus_auto['parameters'].append({ - 'device': 'Auto Filter', - 'parameter': 'Resonance', - 'envelope': self._generate_automation_envelope( - res_start, res_end, section_length, 'ease_in_out' - ), - 'start_value': round(res_start, 3), - 'end_value': round(res_end, 3), - }) - - if bus_auto['parameters']: - automation_data['bus_automation'].append(bus_auto) - - # Build return automation - reverb_settings = base_automation.get('reverb', {}) - delay_settings = base_automation.get('delay', {}) - compression_settings = base_automation.get('compression', {}) - saturation_settings = base_automation.get('saturation', {}) - stereo_width_settings = base_automation.get('stereo_width', {}) - - for return_track in returns: - send_key = str(return_track.get('send_key', '')).lower() - if not send_key: - continue - - return_auto = { - 'send_key': send_key, - 'return_name': return_track.get('name', send_key.upper()), - 'parameters': [] - } - - if send_key == 'space' and reverb_settings: - # Reverb send level - return_auto['parameters'].append({ - 'device': 'Hybrid Reverb', - 'parameter': 'Dry/Wet', - 'envelope': self._generate_automation_envelope( - reverb_settings.get('send_level', 0.2) * 0.9, - reverb_settings.get('send_level', 0.2) * energy_factor, - section_length, curve_name - ), - 'start_value': round(reverb_settings.get('send_level', 0.2) * 0.9, 3), - 'end_value': round(reverb_settings.get('send_level', 0.2) * energy_factor, 3), - }) - # Decay time - return_auto['parameters'].append({ - 'device': 'Hybrid Reverb', - 'parameter': 'Decay Time', - 'envelope': self._generate_automation_envelope( - reverb_settings.get('decay_time', 2.0) * 0.85, - reverb_settings.get('decay_time', 2.0), - section_length, 'ease_out' - ), - 'start_value': round(reverb_settings.get('decay_time', 2.0) * 0.85, 2), - 'end_value': round(reverb_settings.get('decay_time', 2.0), 2), - }) - - elif send_key == 'echo' and delay_settings: - # Delay send level - return_auto['parameters'].append({ - 'device': 'Echo', - 'parameter': 'Dry/Wet', - 'envelope': self._generate_automation_envelope( - delay_settings.get('send_level', 0.15) * 0.85, - delay_settings.get('send_level', 0.15) * energy_factor, - section_length, curve_name - ), - 'start_value': round(delay_settings.get('send_level', 0.15) * 0.85, 3), - 'end_value': round(delay_settings.get('send_level', 0.15) * energy_factor, 3), - }) - # Feedback - return_auto['parameters'].append({ - 'device': 'Echo', - 'parameter': 'Feedback', - 'envelope': self._generate_automation_envelope( - delay_settings.get('feedback', 0.3) * 0.8, - delay_settings.get('feedback', 0.3), - section_length, 'ramp_up' - ), - 'start_value': round(delay_settings.get('feedback', 0.3) * 0.8, 3), - 'end_value': round(delay_settings.get('feedback', 0.3), 3), - }) - - elif send_key == 'heat' and saturation_settings: - # Saturation drive - return_auto['parameters'].append({ - 'device': 'Saturator', - 'parameter': 'Drive', - 'envelope': self._generate_automation_envelope( - saturation_settings.get('drive', 2.0) * 0.6, - saturation_settings.get('drive', 2.0) * energy_factor, - section_length, 'ramp_up' - ), - 'start_value': round(saturation_settings.get('drive', 2.0) * 0.6, 2), - 'end_value': round(saturation_settings.get('drive', 2.0) * energy_factor, 2), - }) - - elif send_key == 'glue' and compression_settings: - # Compressor threshold - return_auto['parameters'].append({ - 'device': 'Compressor', - 'parameter': 'Threshold', - 'envelope': self._generate_automation_envelope( - compression_settings.get('threshold', -12.0) + 3, - compression_settings.get('threshold', -12.0) - (energy_factor - 1) * 2, - section_length, 'ease_in' - ), - 'start_value': round(compression_settings.get('threshold', -12.0) + 3, 1), - 'end_value': round(compression_settings.get('threshold', -12.0) - (energy_factor - 1) * 2, 1), - }) - - if return_auto['parameters']: - automation_data['return_automation'].append(return_auto) - - # Build master automation - automation_data['master_automation'] = { - 'stereo_width': { - 'parameter': 'Stereo Width', - 'envelope': self._generate_automation_envelope( - stereo_width_settings.get('value', 1.0) * 0.9, - stereo_width_settings.get('value', 1.0), - section_length, 'ease_in_out' - ), - 'start_value': round(stereo_width_settings.get('value', 1.0) * 0.9, 3), - 'end_value': round(stereo_width_settings.get('value', 1.0), 3), - }, - 'compression': { - 'parameter': 'Ratio', - 'envelope': self._generate_automation_envelope( - compression_settings.get('ratio', 2.0) * 0.8, - compression_settings.get('ratio', 2.0) * energy_factor, - section_length, 'ease_in' - ), - 'start_value': round(compression_settings.get('ratio', 2.0) * 0.8, 2), - 'end_value': round(compression_settings.get('ratio', 2.0) * energy_factor, 2), - }, - } - - return automation_data - - def _build_full_automation_blueprint( - self, - sections: List[Dict[str, Any]], - buses: List[Dict[str, Any]], - returns: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: - """ - Build complete automation blueprint for all sections. - - Args: - sections: List of section configurations - buses: List of bus track configurations - returns: List of return track configurations - - Returns: - List of automation data dictionaries, one per section - """ - automation_blueprint = [] - - for section in sections: - section_automation = self._build_section_automation(section, buses, returns) - automation_blueprint.append(section_automation) - - return automation_blueprint - - def _build_master_state(self, section_kind: str) -> Dict[str, Any]: - """ - Build master chain state for a section. - - Returns a snapshot payload with flat device parameters for master chain. - """ - section = section_kind.lower() - device_parameters = [] - for device_name, parameter_map in MASTER_DEVICE_AUTOMATION.items(): - for parameter_name, section_values in parameter_map.items(): - value = section_values.get(section, section_values.get('drop', 0.0)) - clamp = MASTER_SAFETY_CLAMPS.get(parameter_name) - if clamp: - value = max(clamp['min'], min(clamp['max'], float(value))) - device_parameters.append({ - 'device_name': device_name, - 'parameter': parameter_name, - 'value': round(float(value), 3), - }) - - return { - 'section': section, - 'device_parameters': device_parameters, - } - - def _build_device_parameters_for_role(self, role: str, section_kind: str) -> List[Dict[str, Any]]: - """ - Build flat device parameter automation entries for a track role in a section. - """ - role_lower = role.lower().replace(' ', '_').replace('-', '_') - if role_lower not in SECTION_DEVICE_AUTOMATION: - return [] - section = section_kind.lower() - device_params = [] - for device_name, parameter_map in SECTION_DEVICE_AUTOMATION.get(role_lower, {}).items(): - for parameter_name, section_values in parameter_map.items(): - value = section_values.get(section, section_values.get('drop', 0.0)) - clamp = DEVICE_PARAMETER_SAFETY_CLAMPS.get(parameter_name) - if clamp: - value = max(clamp['min'], min(clamp['max'], float(value))) - device_params.append({ - 'device_name': device_name, - 'parameter': parameter_name, - 'value': round(float(value), 3), - }) - return device_params - - def _build_bus_device_parameters(self, bus_key: str, section_kind: str) -> List[Dict[str, Any]]: - """ - Build flat device parameter automation entries for a bus track in a section. - Uses BUS_DEVICE_AUTOMATION constant for per-section values. - """ - bus_key_lower = bus_key.lower() - if bus_key_lower not in BUS_DEVICE_AUTOMATION: - return [] - section = section_kind.lower() - device_params = [] - for device_name, parameter_map in BUS_DEVICE_AUTOMATION.get(bus_key_lower, {}).items(): - for parameter_name, section_values in parameter_map.items(): - value = section_values.get(section, section_values.get('drop',0.0)) - clamp = DEVICE_PARAMETER_SAFETY_CLAMPS.get(parameter_name) - if clamp: - value = max(clamp['min'], min(clamp['max'], float(value))) - device_params.append({ - 'device_name': device_name, - 'parameter': parameter_name, - 'value': round(float(value), 3), - }) - return device_params - - def _build_performance_snapshots(self, blueprint_tracks: List[Dict[str, Any]], - sections: List[Dict[str, Any]], - returns: Optional[List[Dict[str, Any]]] = None, - buses: Optional[List[Dict[str, Any]]] = None, - reference_energy_profile: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]: - performance = [] - stereo_roles = {'hat_closed', 'hat_open', 'top_loop', 'perc', 'ride', 'pad', 'pluck', 'arp', 'counter', 'reverse_fx', 'riser', 'impact', 'atmos', 'vocal'} - profile_pan_width = float(self._current_generation_profile.get('pan_width', 0.12)) - volume_factors = { - 'intro': 0.86, - 'build': 0.94, - 'drop': 1.02, - 'break': 0.78, - 'outro': 0.8, - } - - # Build energy profile lookup by section index for adaptive mixing - energy_by_index = {} - if reference_energy_profile: - for i, ep in enumerate(reference_energy_profile): - energy_by_index[i] = ep.get('energy_mean', 0.5) - else: - # Fallback: use section features if available - for i, section in enumerate(sections): - features = section.get('features', {}) - energy_by_index[i] = features.get('energy_mean', features.get('energy', 0.5)) - - space_send_factors = { - 'intro': 1.15, - 'build': 1.0, - 'drop': 0.82, - 'break': 1.35, - 'outro': 1.05, - } - echo_send_factors = { - 'intro': 1.08, - 'build': 1.18, - 'drop': 0.78, - 'break': 1.45, - 'outro': 0.95, - } - heat_send_factors = { - 'intro': 0.55, - 'build': 0.92, - 'drop': 1.18, - 'break': 0.42, - 'outro': 0.72, - } - glue_send_factors = { - 'intro': 0.72, - 'build': 0.96, - 'drop': 1.08, - 'break': 0.58, - 'outro': 0.78, - } - - for section_idx, section in enumerate(sections): - kind = str(section.get('kind', 'drop')).lower() - energy = max(1, int(section.get('energy', 1))) - - # Get energy_mean from reference profile for adaptive volume scaling - ref_energy_mean = energy_by_index.get(section_idx, 0.5) - - snapshot = { - 'scene_index': int(section.get('index', len(performance))), - 'name': section.get('name', "SECTION"), - 'track_states': [], - 'return_states': self._build_return_states(list(returns or []), section), - 'bus_states': [], - } - - for track_index, track_data in enumerate(blueprint_tracks): - role = track_data.get('role', '') - base_volume = float(track_data.get('volume', 0.72)) - base_pan = float(track_data.get('pan', 0.0)) - base_sends = dict(track_data.get('sends', {})) - intensity = self._role_intensity(role, section) - is_muted = role != 'sc_trigger' and intensity <= 0 - - if is_muted: - target_volume = round(base_volume * 0.08, 3) - else: - factor = volume_factors.get(kind, 1.0) + max(0.0, (energy - 3) * 0.03) - if role in ['kick', 'sub_bass', 'bass'] and kind == 'drop': - factor += 0.04 - if role in ['pad', 'atmos', 'drone'] and kind == 'break': - factor += 0.08 - if role in ['reverse_fx', 'riser', 'impact'] and kind in ['build', 'break']: - factor += 0.06 * float(self._current_generation_profile.get('fx_bias', 1.0)) - - # Apply energy-based volume scaling from reference profile - if ref_energy_mean < 0.3: - # Quiet sections (intro, quiet breaks) - reduce volume - energy_volume_factor = 0.85 - elif ref_energy_mean > 0.7: - # High energy sections (drops, peaks) - boost volume - energy_volume_factor = 1.08 - else: - energy_volume_factor = 1.0 - - target_volume = round(min(1.0, max(0.0, base_volume * factor * energy_volume_factor)), 3) - - target_pan = base_pan - pan_variant = str(section.get('pan_variant', 'narrow')).lower() - if role in stereo_roles: - if pan_variant == 'tilt_left': - direction = -1.0 - width = profile_pan_width - elif pan_variant == 'tilt_right': - direction = 1.0 - width = profile_pan_width - elif pan_variant == 'wide': - direction = -1.0 if track_index % 2 == 0 else 1.0 - width = profile_pan_width * 1.1 - else: - direction = -1.0 if track_index % 2 == 0 else 1.0 - width = profile_pan_width * 0.55 - - if kind == 'break': - width *= 1.18 - elif kind == 'drop': - width *= 0.92 - target_pan = self._clamp_pan(base_pan + (direction * width)) - - target_sends = {} - for send_name, send_value in base_sends.items(): - send_factor = 1.0 - if send_name == 'space': - send_factor = space_send_factors.get(kind, 1.0) - elif send_name == 'echo': - send_factor = echo_send_factors.get(kind, 1.0) - elif send_name == 'heat': - send_factor = heat_send_factors.get(kind, 1.0) - elif send_name == 'glue': - send_factor = glue_send_factors.get(kind, 1.0) - - if role in ['riser', 'impact'] and kind in ['build', 'break']: - send_factor += 0.18 - if role == 'vocal' and kind in ['build', 'drop']: - send_factor += 0.12 - if role in ['kick', 'sub_bass', 'bass'] and send_name in ['heat', 'glue'] and kind == 'drop': - send_factor += 0.1 - if is_muted: - send_factor *= 0.25 - - target_sends[send_name] = round(min(1.0, max(0.0, float(send_value) * send_factor)), 3) - - track_state = { - 'track_index': track_index, - 'role': role, - 'mute': is_muted, - 'volume': target_volume, - 'pan': target_pan, - 'sends': target_sends, - } - - # Add device_parameters to track state - device_params = self._build_device_parameters_for_role(role, kind) - if device_params: - track_state['device_parameters'] = device_params - - snapshot['track_states'].append(track_state) - - # Add bus states to snapshot - for bus_data in list(buses or []): - bus_key = str(bus_data.get('key', '')).lower() - if not bus_key: - continue - bus_device_params = self._build_bus_device_parameters(bus_key, kind) - if bus_device_params: - bus_state = { - 'bus_key': bus_key, - 'bus_name': bus_data.get('name', bus_key.upper()), - 'device_parameters': bus_device_params, - } - snapshot['bus_states'].append(bus_state) - - # Add master state to snapshot - master_state = self._build_master_state(kind) - if master_state.get('device_parameters'): - snapshot['master_state'] = master_state - - performance.append(snapshot) - - return performance - - def _build_mix_automation_summary(self, performance: List[Dict]) -> Dict[str, Any]: - """ - Build summary of automation in performance snapshots. - - Returns: - - track_snapshots_with_device_automation: count - - return_snapshots_with_device_automation: count - - bus_snapshots_with_device_automation: count - - master_snapshots_count: count - - track_roles_touched: list of roles with device automation - - bus_keys_touched: list of bus keys with device automation - - master_parameters_touched: list of master params automated - """ - track_count = 0 - return_count = 0 - bus_count = 0 - master_count = 0 - track_roles = set() - bus_keys = set() - master_params = set() - - for snapshot in performance: - # Check track states - for track_state in snapshot.get('track_states', []): - if 'device_parameters' in track_state and track_state['device_parameters']: - track_count += 1 - role = track_state.get('role', 'unknown') - track_roles.add(role) - - # Check return states - for return_state in snapshot.get('return_states', []): - if 'device_parameters' in return_state and return_state['device_parameters']: - return_count += 1 - - # Check bus states - for bus_state in snapshot.get('bus_states', []): - if 'device_parameters' in bus_state and bus_state['device_parameters']: - bus_count += 1 - bus_key = bus_state.get('bus_key', 'unknown') - bus_keys.add(bus_key) - - # Check master state - master_state = snapshot.get('master_state', {}) - if master_state.get('device_parameters'): - master_count += 1 - for item in master_state.get('device_parameters', []): - param_name = str(item.get('parameter', '') or '').strip() - if param_name: - master_params.add(param_name) - - return { - 'track_snapshots_with_device_automation': track_count, - 'return_snapshots_with_device_automation': return_count, - 'bus_snapshots_with_device_automation': bus_count, - 'master_snapshots_count': master_count, - 'track_roles_touched': sorted(list(track_roles)), - 'bus_keys_touched': sorted(list(bus_keys)), - 'master_parameters_touched': sorted(list(master_params)) - } - - def _verify_automation_safety(self, performance: List[Dict]) -> List[str]: - """ - Verify automation values are within safe ranges. - - Returns list of warnings if any values are outside safe ranges. - """ - warnings = [] - - for i, snapshot in enumerate(performance): - # Check master state - master_state = snapshot.get('master_state', {}) - for item in master_state.get('device_parameters', []): - device_name = str(item.get('device_name', 'unknown')) - param_name = str(item.get('parameter', '') or '').strip() - value = float(item.get('value', 0.0)) - clamp = MASTER_SAFETY_CLAMPS.get(param_name) - if clamp and (value < clamp['min'] or value > clamp['max']): - warnings.append(f"Snapshot {i}: {device_name}.{param_name}={value} outside safe range [{clamp['min']}, {clamp['max']}]") - - return warnings - - def _build_gain_staging_summary(self, config: Dict[str, Any]) -> Dict[str, Any]: - """ - Build gain staging summary for the generated config. - """ - warnings = [] - - # Check bus volumes for extreme values - bus_volumes = self._calibrated_bus_volumes or {} - for bus_name, vol in bus_volumes.items(): - if vol > 0.9: - warnings.append(f"Bus {bus_name} volume > 0.9: {vol:.3f}") - - # Check master limiter gain - master = config.get('master', {}) - master_limiter_gain = 0.0 - for device in master.get('device_chain', []): - if device.get('device') == 'Limiter': - master_limiter_gain = device.get('parameters', {}).get('Gain', 0.0) - if master_limiter_gain > 1.0: - warnings.append(f"Master limiter gain > 1.0: {master_limiter_gain:.3f}") - - # Check track volumes - for track in config.get('tracks', []): - vol = track.get('volume', 0.0) - role = track.get('role', 'unknown') - if vol > 0.9: - warnings.append(f"Track {role} volume > 0.9: {vol:.3f}") - - return { - 'master_profile_used': getattr(self, '_master_profile_used', 'default'), - 'style_adjustments_applied': getattr(self, '_style_adjustments_applied', []), - 'bus_volumes': bus_volumes, - 'track_volume_overrides_count': getattr(self, '_gain_calibration_overrides_count', 0), - 'peak_reductions_applied_count': getattr(self, '_peak_reductions_count', 0), - 'headroom_target_db': TARGET_HEADROOM_DB, - 'warnings': warnings, - } - - def generate_config(self, genre: str, style: str = "", bpm: float = 0, - key: str = "", structure: str = "standard") -> Dict[str, Any]: - """ - Genera una configuración completa de track - - Args: - genre: Género musical - style: Sub-estilo - bpm: BPM (0 = auto) - key: Tonalidad ("" = auto) - structure: Tipo de estructura - """ - genre = genre.lower().replace(' ', '-') - style = style.lower() if style else "" - variant_seed = random.SystemRandom().randint(1000, 999999) - random.seed(variant_seed) - - # Decay pattern variant memory to allow reuse - _decay_pattern_variant_memory() - - # Reset gain staging counters - self._gain_calibration_overrides_count = 0 - self._peak_reductions_count = 0 - self._style_adjustments_applied = [] - self._calibrated_bus_volumes = {} - self._master_profile_used = 'default' - - reference_resolution = self._resolve_reference_track_profile(genre, style, bpm, key, structure) - if reference_resolution: - genre = reference_resolution.get('genre', genre) or genre - style = reference_resolution.get('style', style) - bpm = float(reference_resolution.get('bpm', bpm or 0)) - key = reference_resolution.get('key', key) - structure = reference_resolution.get('structure', structure) - - # Obtener configuración del género - genre_config = GENRE_CONFIGS.get(genre, GENRE_CONFIGS['techno']) - - # Determinar BPM - if bpm <= 0: - bpm = genre_config['default_bpm'] - - # Determinar key - if not key: - key = random.choice(genre_config['keys']) - - # Determinar estilo si no se especificó - if not style: - style = random.choice(genre_config['styles']) - - # Parsear key - _root_note = key[:-1] if len(key) > 1 else key # noqa: F841 - parsed when needed per section - is_minor = 'm' in key.lower() - scale = 'minor' if is_minor else 'major' - profile = self._build_arrangement_profile(genre, style, variant_seed) - profile['style_text'] = f"{genre} {style}".strip().lower() - profile['reference_name'] = str(((reference_resolution or {}).get('reference') or {}).get('name', '')).lower() - self._current_generation_profile = profile - sections = self._build_sections(structure, style, variant_seed, profile) - - # Crear configuración base - config = { - 'name': f"{genre.title()} {style.title()}", - 'bpm': bpm, - 'key': key, - 'scale': scale, - 'genre': genre, - 'style': style, - 'structure': structure, - 'variant_seed': variant_seed, - 'arrangement_profile': profile['name'], - 'reference_track': reference_resolution.get('reference') if reference_resolution else None, - 'reference_energy_profile': reference_resolution.get('reference_energy_profile') if reference_resolution else None, - 'auto_generate': True, - 'sections': sections, - 'buses': self._build_mix_bus_blueprint(profile, genre, style, reference_resolution), - 'returns': self._build_return_blueprint(profile, genre, style, reference_resolution), - 'master': self._build_master_blueprint(profile, genre, style, reference_resolution), - 'tracks': [], - } - - # Generar tracks según género - config['tracks'] = self._generate_tracks_for_genre(genre, style, key, scale, structure, sections, profile) - config['performance'] = self._build_performance_snapshots(config['tracks'], sections, config.get('returns', []), config.get('buses', [])) - config['mix_automation_summary'] = self._build_mix_automation_summary(config['performance']) - config['mix_automation_warnings'] = self._verify_automation_safety(config['performance']) - config['gain_staging_summary'] = self._build_gain_staging_summary(config) - config['automation'] = self._build_full_automation_blueprint(sections, config.get('buses', []), config.get('returns', [])) - config['transition_events'] = self._generate_transition_events(sections) - - # Apply density rules to prevent overcrowding - config['transition_events'] = self._apply_transition_density_rules(config['transition_events'], sections) - - # Materialize transition events into track blueprints - config['tracks'] = self._materialize_transition_events(config, config['tracks']) - - config['locators'] = self._build_locators(sections) - config['total_bars'] = sum(section['bars'] for section in sections) - config['total_beats'] = float(config['total_bars'] * 4) - - # Add section variants summary - config['section_variants'] = { - section.get('name', f'section_{i}'): { - 'kind': section.get('kind', 'unknown'), - 'drum_variant': section.get('drum_variant', 'straight'), - 'kick_variant': section.get('kick_variant', (section.get('drum_role_variants') or {}).get('kick', 'straight')), - 'clap_variant': section.get('clap_variant', (section.get('drum_role_variants') or {}).get('clap', 'straight')), - 'hat_closed_variant': section.get('hat_closed_variant', (section.get('drum_role_variants') or {}).get('hat_closed', 'straight')), - 'bass_variant': section.get('bass_variant', 'anchor'), - 'bass_bank_variant': section.get('bass_bank_variant', section.get('bass_variant', 'anchor')), - 'melodic_variant': section.get('melodic_variant', 'motif'), - 'melodic_bank_variant': section.get('melodic_bank_variant', section.get('melodic_variant', 'motif')), - 'transition_fill': section.get('transition_fill', 'none'), - } - for i, section in enumerate(sections) - } - - # Crear summary - config['summary'] = f""" -🎵 Track Generado: {config['name']} -♩ BPM: {bpm} -🎹 Key: {key} -🎨 Style: {style} -📊 Tracks: {len(config['tracks'])} -""" - if config.get('reference_track'): - config['summary'] += f"🔊 Reference: {config['reference_track'].get('name')}\n" - - return config - - def _build_locators(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - locators = [] - arrangement_time = 0.0 - for section in sections: - locators.append({ - 'scene_index': int(section.get('index', len(locators))), - 'name': section.get('name', 'SECTION'), - 'bars': int(section.get('bars', 8)), - 'color': int(section.get('color', 10)), - 'time_beats': round(arrangement_time, 3), - }) - arrangement_time += float(section.get('beats', 0.0) or 0.0) - return locators - - def _generate_tracks_for_genre(self, genre: str, style: str, key: str, - scale: str, structure: str, sections: List[Dict[str, Any]], - profile: Optional[Dict[str, Any]] = None) -> List[Dict]: - """Genera la configuración de tracks según el género""" - track_specs = [] - style_text = f"{genre} {style}".lower() - - track_specs.extend([ - ('SC TRIGGER', 'sc_trigger', TRACK_COLORS['technical'], 'operator'), - ('KICK', 'kick', TRACK_COLORS['kick'], 'operator'), - ('CLAP', 'clap', TRACK_COLORS['clap'], 'operator'), - ('SNARE FILL', 'snare_fill', TRACK_COLORS['snare'], 'operator'), - ('HAT CLOSED', 'hat_closed', TRACK_COLORS['hat'], 'operator'), - ('HAT OPEN', 'hat_open', TRACK_COLORS['hat'], 'operator'), - ('TOP LOOP', 'top_loop', TRACK_COLORS['hat'], 'operator'), - ('PERCUSSION', 'perc', TRACK_COLORS['perc'], 'operator'), - ('TOM FILL', 'tom_fill', TRACK_COLORS['perc'], 'operator'), - ('SUB BASS', 'sub_bass', TRACK_COLORS['bass'], 'operator'), - ('BASS', 'bass', TRACK_COLORS['bass'], 'operator'), - ('DRONE', 'drone', TRACK_COLORS['pad'], 'analog'), - ('CHORDS', 'chords', TRACK_COLORS['chords'], 'wavetable'), - ('STAB', 'stab', TRACK_COLORS['synth'], 'operator'), - ('PAD', 'pad', TRACK_COLORS['pad'], 'wavetable'), - ('ARP', 'arp', TRACK_COLORS['synth'], 'operator'), - ('LEAD', 'lead', TRACK_COLORS['synth'], 'wavetable'), - ('COUNTER', 'counter', TRACK_COLORS['synth'], 'operator'), - ('CRASH', 'crash', TRACK_COLORS['fx'], 'operator'), - ('REVERSE FX', 'reverse_fx', TRACK_COLORS['fx'], 'analog'), - ('RISER FX', 'riser', TRACK_COLORS['fx'], 'operator'), - ('IMPACT FX', 'impact', TRACK_COLORS['fx'], 'operator'), - ('ATMOS', 'atmos', TRACK_COLORS['fx'], 'analog'), - ]) - tracks = [] - - # Synths/Chords según género - if genre in ['house', 'trance', 'progressive']: - tracks.append(self._generate_chord_track(key, scale, genre)) - tracks.append(self._generate_lead_track(key, scale, genre)) - elif genre in ['techno', 'tech-house']: - if random.random() > 0.3: # 70% de probabilidad - tracks.append(self._generate_chord_track(key, scale, genre)) - if random.random() > 0.5: - tracks.append(self._generate_lead_track(key, scale, genre)) - - # FX/Atmósfera para estructuras extended - if structure in ['extended', 'club'] or random.random() > 0.6: - tracks.append(self._generate_fx_track()) - - if genre in ['techno', 'tech-house', 'trance']: - track_specs.insert(8, ('RIDE', 'ride', TRACK_COLORS['ride'], 'operator')) - if genre in ['house', 'tech-house', 'trance'] or 'latin' in style_text: - track_specs.insert(14, ('PLUCK', 'pluck', TRACK_COLORS['synth'], 'wavetable')) - track_specs.insert(15, ('VOCAL CHOP', 'vocal', TRACK_COLORS['vocal'], 'wavetable')) - elif genre == 'drum-and-bass': - track_specs = [ - ('BREAK', 'kick', TRACK_COLORS['kick'], 'operator'), - ('SNARE', 'clap', TRACK_COLORS['snare'], 'operator'), - ('HATS', 'hat_closed', TRACK_COLORS['hat'], 'operator'), - ('PERCUSSION', 'perc', TRACK_COLORS['perc'], 'operator'), - ('SUB BASS', 'sub_bass', TRACK_COLORS['bass'], 'operator'), - ('REESE', 'bass', TRACK_COLORS['bass'], 'operator'), - ('PAD', 'pad', TRACK_COLORS['pad'], 'wavetable'), - ('ARP', 'arp', TRACK_COLORS['synth'], 'operator'), - ('LEAD', 'lead', TRACK_COLORS['synth'], 'wavetable'), - ('VOCAL', 'vocal', TRACK_COLORS['vocal'], 'wavetable'), - ('RISER FX', 'riser', TRACK_COLORS['fx'], 'operator'), - ('ATMOS', 'atmos', TRACK_COLORS['fx'], 'analog'), - ] - - blueprint_tracks = [] - active_profile = dict(profile or self._current_generation_profile or {'name': 'default'}) - # NTH-04: Resolve genre-specific colors when available - genre_palette = GENRE_COLOR_PALETTES.get(genre, {}) - for name, role, default_color, device in track_specs: - clips = self._build_scene_clips(role, genre, style, key, scale, sections) - if not clips: - continue - - mix_profile = dict(ROLE_MIX.get(role, {})) - mix_profile['sends'] = self._extend_parallel_sends(role, mix_profile.get('sends', {})) - mix_profile = self._shape_mix_profile(role, mix_profile, active_profile, style) - # NTH-04: Use genre-specific color if available, otherwise default - resolved_color = genre_palette.get(role, default_color) - track = { - 'name': name, - 'type': 'midi', - 'role': role, - 'bus': self._resolve_bus_for_role(role), - 'device': device, - 'color': resolved_color, - 'volume': mix_profile.get('volume', 0.72), - 'pan': mix_profile.get('pan', 0.0), - 'sends': dict(mix_profile.get('sends', {})), - 'fx_chain': self._shape_role_fx_chain(role, active_profile, style), - 'clips': clips, - } - track['clip'] = dict(clips[0]) - - # Agregar metadata de variación al blueprint - if role in SECTION_VARIATION_CONFIG: - track['section_variation'] = SECTION_VARIATION_CONFIG[role] - track['can_vary_by_section'] = True - - blueprint_tracks.append(track) - - return blueprint_tracks - - def _build_sections(self, structure: str, style: str = "", variant_seed: Optional[int] = None, - profile: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: - structure_key = structure.lower() - rng = random.Random(variant_seed) if variant_seed is not None else random - blueprint_options = SECTION_BLUEPRINT_VARIANTS.get(structure_key) - if blueprint_options: - if 'latin' in style and structure_key == 'club' and len(blueprint_options) > 1: - blueprint = rng.choice(blueprint_options[1:]) - else: - blueprint = rng.choice(blueprint_options) - else: - blueprint = SECTION_BLUEPRINTS.get(structure_key, SECTION_BLUEPRINTS['standard']) - sections = [] - style_text = style.lower() if style else "" - profile_name = str((profile or {}).get('name', 'default')).lower() - for index, (name, bars, color, kind, energy) in enumerate(blueprint): - if kind == 'intro': - drum_variants = ['straight', 'skip'] - bass_variants = ['anchor', 'pedal'] - melodic_variants = ['motif', 'response'] - elif kind == 'build': - drum_variants = ['shuffle', 'pressure', 'straight'] - bass_variants = ['bounce', 'syncopated'] - melodic_variants = ['lift', 'response'] - elif kind == 'break': - drum_variants = ['skip', 'shuffle'] - bass_variants = ['pedal', 'anchor'] - melodic_variants = ['drone', 'response'] - elif kind == 'outro': - drum_variants = ['straight', 'skip'] - bass_variants = ['anchor', 'pedal'] - melodic_variants = ['motif', 'descend'] - else: - drum_variants = ['straight', 'pressure', 'shuffle'] - bass_variants = ['syncopated', 'bounce', 'anchor'] - melodic_variants = ['lift', 'motif', 'descend'] - - swing_pool = [0.0, 0.015, 0.025] - if 'latin' in style_text or profile_name in ['jackin', 'swing']: - swing_pool.extend([0.035, 0.045, 0.055]) - - pan_variant = rng.choice(['narrow', 'wide', 'tilt_left', 'tilt_right']) - if kind in ['intro', 'outro'] and rng.random() > 0.5: - pan_variant = 'narrow' - if kind == 'break' and rng.random() > 0.4: - pan_variant = 'wide' - - section_data = { - 'index': index, - 'name': name, - 'bars': int(bars), - 'beats': float(bars * 4), - 'color': color, - 'kind': kind, - 'energy': int(energy), - 'density': round(min(1.35, max(0.68, 0.78 + (energy * 0.08) + rng.uniform(-0.08, 0.14))), 3), - 'swing': round(rng.choice(swing_pool), 3), - 'tension': int(min(5, max(1, energy + rng.choice([-1, 0, 0, 1])))), - 'drum_variant': rng.choice(drum_variants), - 'bass_variant': rng.choice(bass_variants), - 'melodic_variant': rng.choice(melodic_variants), - 'pan_variant': pan_variant, - 'transition_fill': rng.choice(['none', 'snare', 'tom', 'reverse', 'impact']), - } - sections.append(self._ensure_section_pattern_variants(section_data)) - # Check for excessive repetition and force variation if needed - sections = self._check_section_repetition(sections) - return sections - - def _role_intensity(self, role: str, section: Dict[str, Any]) -> int: - kind = section.get('kind', 'drop') - energy = int(section.get('energy', 1)) - role_energy = ROLE_ACTIVITY.get(role, {}).get(kind, 0) - return min(max(role_energy, 0), max(1, energy + 1)) - - def _build_scene_clips(self, role: str, genre: str, style: str, key: str, - scale: str, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - clips = [] - for section in sections: - notes = self._render_scene_notes(role, genre, style, key, scale, section) - if not notes: - continue - - clips.append({ - 'scene_index': section['index'], - 'length': section['beats'], - 'name': f"{role.upper()} - {section['name']}", - 'notes': notes, - }) - return clips - - def _render_scene_notes(self, role: str, genre: str, style: str, key: str, - scale: str, section: Dict[str, Any]) -> List[Dict[str, Any]]: - intensity = self._role_intensity(role, section) - if intensity <= 0: - return [] - - if role in ['sc_trigger', 'kick', 'clap', 'snare_fill', 'hat_closed', 'hat_open', 'top_loop', 'perc', 'tom_fill', 'ride', 'crash']: - return self._render_drum_scene(role, genre, style, section, intensity) - if role in ['sub_bass', 'bass']: - return self._render_bass_scene(role, genre, style, key, section) - if role in ['chords', 'stab', 'pad', 'pluck', 'arp', 'lead', 'counter']: - return self._render_musical_scene(role, genre, key, scale, section) - if role in ['drone', 'reverse_fx', 'riser', 'impact', 'atmos', 'vocal']: - return self._render_fx_scene(role, key, section) - return [] - - def _render_drum_scene(self, role: str, genre: str, style: str, - section: Dict[str, Any], intensity: int) -> List[Dict[str, Any]]: - total_length = float(section['beats']) - kind = section['kind'] - style_text = f"{genre} {style}".lower() - - if role == 'sc_trigger': - pattern = [self._make_note(24, beat, 0.12, 127) for beat in [0.0, 1.0, 2.0, 3.0]] - if kind == 'break': - pattern = [self._make_note(24, beat, 0.1, 118) for beat in [0.0, 2.0]] - return self._repeat_pattern(pattern, total_length, 4.0) - - if role == 'kick': - if genre == 'drum-and-bass': - pattern = [ - self._make_note(36, 0.0, 0.25, 122), - self._make_note(36, 0.75, 0.2, 104), - self._make_note(36, 1.5, 0.2, 112), - self._make_note(36, 2.0, 0.25, 124), - self._make_note(36, 2.75, 0.2, 100), - self._make_note(36, 3.25, 0.2, 92), - ] - elif kind == 'break': - pattern = [ - self._make_note(36, 0.0, 0.25, 118), - self._make_note(36, 2.0, 0.25, 110), - ] - else: - pattern = [self._make_note(36, beat, 0.25, 126 if beat == 0 else 118) for beat in [0.0, 1.0, 2.0, 3.0]] - if intensity >= 4 and genre in ['techno', 'tech-house']: - pattern.append(self._make_note(36, 3.5, 0.15, 94)) - notes = self._repeat_pattern(pattern, total_length, 4.0) - if kind in ['build', 'drop', 'outro']: - notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) - return self._vary_drum_notes(notes, role, section, total_length) - - if role == 'clap': - pitch = 38 if genre == 'drum-and-bass' else 39 - if kind == 'intro': - pattern = [self._make_note(pitch, 3.0, 0.2, 88)] - elif kind == 'break': - pattern = [self._make_note(pitch, 1.0, 0.2, 84)] - else: - pattern = [ - self._make_note(pitch, 1.0, 0.25, 108), - self._make_note(pitch, 3.0, 0.25, 108), - ] - notes = self._repeat_pattern(pattern, total_length, 4.0) - if kind in ['build', 'drop']: - notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) - return self._vary_drum_notes(notes, role, section, total_length) - - if role == 'snare_fill': - if kind not in ['build', 'break', 'drop']: - return [] - if str(section.get('transition_fill', 'snare')).lower() not in ['snare', 'impact'] and kind != 'drop': - return [] - fill_span = 2.0 if kind == 'build' and total_length >= 8.0 else 1.0 - fill_start = max(0.0, total_length - fill_span) - step = 0.25 if intensity <= 2 else 0.125 - velocity = 76 - notes = [] - current = fill_start - while current < total_length - 0.01: - notes.append(self._make_note(38, current, 0.08 if step < 0.2 else 0.12, min(124, velocity))) - current += step - velocity += 3 - if kind == 'drop': - notes.insert(0, self._make_note(38, 0.0, 0.15, 102)) - return self._vary_drum_notes(notes, role, section, total_length) - - if role == 'hat_closed': - if intensity <= 1: - pattern = [self._make_note(42, beat, 0.1, 86) for beat in [0.5, 1.5, 2.5, 3.5]] - elif intensity == 2: - pattern = [self._make_note(42, step * 0.5, 0.1, 90 if step % 2 == 0 else 72) for step in range(8)] - else: - pattern = [self._make_note(42, step * 0.5, 0.1, 92 if step % 2 == 0 else 74) for step in range(8)] - pattern.extend([self._make_note(42, 1.75, 0.08, 64), self._make_note(42, 3.75, 0.08, 62)]) - notes = self._repeat_pattern(pattern, total_length, 4.0) - if kind in ['build', 'drop', 'outro']: - notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) - return self._vary_drum_notes(notes, role, section, total_length) - - if role == 'hat_open': - if kind in ['intro', 'break'] and intensity <= 1: - return [] - pattern = [self._make_note(46, 3.5, 0.35, 82)] - if intensity >= 3: - pattern.append(self._make_note(46, 1.5, 0.25, 74)) - notes = self._repeat_pattern(pattern, total_length, 4.0) - if kind in ['build', 'drop']: - notes = self._merge_section_notes(notes, self._build_drum_fill(role, total_length, intensity), total_length) - return self._vary_drum_notes(notes, role, section, total_length) - - if role == 'top_loop': - if kind in ['intro', 'break'] and intensity <= 1: - return [] - pattern = [ - self._make_note(44, 0.25, 0.08, 56), - self._make_note(44, 0.75, 0.08, 62), - self._make_note(44, 1.25, 0.08, 58), - self._make_note(44, 1.75, 0.08, 66), - self._make_note(44, 2.25, 0.08, 58), - self._make_note(44, 2.75, 0.08, 64), - self._make_note(44, 3.25, 0.08, 60), - self._make_note(44, 3.75, 0.08, 68), - ] - if 'latin' in style_text: - pattern.extend([ - self._make_note(54, 0.5, 0.08, 52), - self._make_note(54, 2.5, 0.08, 54), - ]) - if intensity >= 3: - pattern.extend([ - self._make_note(44, 1.125, 0.06, 48), - self._make_note(44, 3.125, 0.06, 50), - ]) - return self._vary_drum_notes(self._repeat_pattern(pattern, total_length, 4.0), role, section, total_length) - - if role == 'perc': - if kind in ['intro', 'outro'] and intensity <= 1: - return [] - pattern = [ - self._make_note(37, 0.75, 0.1, 62), - self._make_note(37, 1.25, 0.1, 58), - self._make_note(37, 2.75, 0.1, 64), - self._make_note(50, 3.25, 0.12, 70), - ] - if 'latin' in style_text: - pattern.extend([ - self._make_note(64, 1.75, 0.12, 68), - self._make_note(64, 2.125, 0.12, 64), - ]) - if intensity >= 3: - pattern.extend([self._make_note(37, 0.25, 0.1, 56), self._make_note(47, 2.25, 0.1, 68)]) - return self._vary_drum_notes(self._repeat_pattern(pattern, total_length, 4.0), role, section, total_length) - - if role == 'tom_fill': - if kind not in ['build', 'drop']: - return [] - if str(section.get('transition_fill', 'tom')).lower() not in ['tom', 'impact'] and kind != 'drop': - return [] - fill_start = max(0.0, total_length - 1.0) - sequence = [47, 50, 45, 47, 50] - velocities = [72, 76, 80, 88, 96] - notes = [] - for index, pitch in enumerate(sequence): - start = fill_start + (index * 0.2) - if start >= total_length: - break - notes.append(self._make_note(pitch, start, 0.18, velocities[index])) - return self._vary_drum_notes(notes, role, section, total_length) - - if role == 'ride': - if kind not in ['build', 'drop', 'outro']: - return [] - pattern = [self._make_note(51, float(beat), 0.2, 82) for beat in range(4)] - if intensity >= 3: - pattern.extend([self._make_note(51, beat + 0.5, 0.15, 64) for beat in range(4)]) - return self._vary_drum_notes(self._repeat_pattern(pattern, total_length, 4.0), role, section, total_length) - - if role == 'crash': - if kind not in ['build', 'drop', 'break', 'outro']: - return [] - hit_positions = [0.0] - if kind == 'drop' and total_length >= 16.0: - hit_positions.append(8.0) - if kind == 'outro' and total_length >= 8.0: - hit_positions.append(total_length - 4.0) - notes = [ - self._make_note(49, position, min(1.5, max(0.25, total_length - position)), 82 if position == 0.0 else 70) - for position in hit_positions - if position < total_length - ] - return self._vary_drum_notes(notes, role, section, total_length) - - return [] - - def _bass_style_for_section(self, genre: str, style: str, role: str, section_kind: str) -> str: - style_text = f"{genre} {style}".lower() - if role == 'sub_bass': - return 'minimal' if section_kind != 'drop' else 'offbeat' - if 'acid' in style_text: - return 'acid' - if genre == 'house': - return 'offbeat' - if genre == 'drum-and-bass': - return 'rolling' - if section_kind in ['intro', 'outro', 'break']: - return 'minimal' - if genre == 'tech-house': - return 'offbeat' - return 'rolling' - - def _render_bass_scene(self, role: str, genre: str, style: str, key: str, - section: Dict[str, Any]) -> List[Dict[str, Any]]: - total_length = float(section['beats']) - kind = section['kind'] - scale_name = 'minor' if 'm' in key.lower() else 'major' - - if kind == 'break': - notes = self._build_pad_motion(key, scale_name, total_length, 2, 4.0) - else: - notes = self.create_bassline(key, self._bass_style_for_section(genre, style, role, kind), total_length) - - if role == 'sub_bass': - notes = self._transpose_notes(notes, -12) - notes = self._scale_note_lengths(notes, 1.35, minimum=0.2) - notes = self._vary_bass_notes(notes, role, key, section, total_length) - if kind in ['build', 'drop'] and total_length >= 8.0: - turnaround = self._build_turnaround_notes(key, scale_name, total_length, 2 if role == 'bass' else 1, 88 if role == 'bass' else 80) - notes = self._merge_section_notes(notes, turnaround, total_length) - return notes - - def _render_musical_scene(self, role: str, genre: str, key: str, scale: str, - section: Dict[str, Any]) -> List[Dict[str, Any]]: - total_length = float(section['beats']) - kind = section['kind'] - - if role == 'pad': - notes = self._build_pad_motion(key, scale, total_length, 4, 8.0 if kind == 'break' else 4.0) - return self._vary_melodic_notes(notes, role, key, scale, section, total_length) - - if role == 'chords': - progression_type = 'techno' if genre in ['techno', 'tech-house'] else ('trance' if genre == 'trance' else 'house') - notes = self.create_chord_progression(key, progression_type, total_length) - notes = self._scale_note_lengths(notes, 1.15, minimum=0.25) - return self._vary_melodic_notes(notes, role, key, scale, section, total_length) - - if role == 'stab': - notes = self.create_chord_progression(key, 'techno' if genre in ['techno', 'tech-house'] else 'house', total_length) - notes = self._scale_note_lengths(notes, 0.4, minimum=0.1) - shifted = [] - for note in notes: - start = float(note['start']) + (0.5 if int(float(note['start'])) % 2 == 0 else 0.0) - shifted.append(self._make_note(note['pitch'], start, note['duration'], min(118, note['velocity'] + 6))) - return self._vary_melodic_notes(shifted, role, key, scale, section, total_length) - - if role == 'pluck': - notes = self.create_melody(key, scale, total_length, genre) - notes = self._scale_note_lengths(notes, 0.55, minimum=0.12) - return self._vary_melodic_notes(notes, role, key, scale, section, total_length) - - notes = self.create_melody(key, scale, total_length, genre) - if role == 'arp': - notes = self._scale_note_lengths(notes, 0.45, minimum=0.1) - elif role == 'lead': - notes = self._transpose_notes(notes, 12) - elif role == 'counter': - sparse = [] - for note in notes: - start = float(note['start']) - if (start % 4.0) < 2.0: - continue - sparse.append(self._make_note(note['pitch'] - 12, start, max(0.2, float(note['duration']) * 0.8), max(50, int(note['velocity']) - 10))) - notes = sparse - notes = self._vary_melodic_notes(notes, role, key, scale, section, total_length) - if role in ['lead', 'arp', 'pluck', 'counter'] and kind in ['build', 'drop'] and total_length >= 8.0: - notes = self._merge_section_notes(notes, self._build_turnaround_notes(key, scale, total_length, 5, 84), total_length) - return notes - - def _render_fx_scene(self, role: str, key: str, section: Dict[str, Any]) -> List[Dict[str, Any]]: - total_length = float(section['beats']) - kind = section.get('kind', 'drop') - root_note = key[:-1] if len(key) > 1 else key - root_midi = self.note_name_to_midi(root_note, 5) - rng = self._section_rng(section, role, salt=19) - - if role == 'drone': - notes = [ - self._make_note(root_midi - 12, 0.0, min(total_length, 8.0 if kind == 'break' else total_length), 58), - self._make_note(root_midi - 5, max(0.0, total_length / 2.0), min(total_length / 2.0, 8.0), 52), - ] - if kind in ['build', 'drop'] and total_length >= 12.0: - notes.append(self._make_note(root_midi + 2, max(0.0, total_length - 6.0), 4.0, 48)) - return notes - - if role == 'reverse_fx': - if str(section.get('transition_fill', 'reverse')).lower() not in ['reverse', 'impact'] and kind not in ['break', 'build']: - return [] - notes = [] - for span, offset, velocity in ((4.0, 4.0, 70), (2.0, 2.0, 64), (1.0, 1.0, 58)): - if total_length >= offset: - start = max(0.0, total_length - offset) - notes.append(self._make_note(root_midi + 12, start, min(span, total_length - start), velocity)) - if kind == 'build' and total_length >= 16.0 and rng.random() > 0.35: - notes.append(self._make_note(root_midi + 7, max(0.0, total_length - 8.0), 1.5, 56)) - return notes - - if role == 'riser': - notes = [] - sweep_start = max(0.0, total_length - min(8.0, total_length)) - for offset, pitch, velocity in ((0.0, root_midi + 7, 64), (2.0, root_midi + 12, 70), (4.0, root_midi + 19, 74), (6.0, root_midi + 24, 78)): - start = sweep_start + offset - if start < total_length: - notes.append(self._make_note(pitch, start, min(2.0, total_length - start), velocity)) - if kind == 'build' and total_length >= 8.0: - notes.extend([ - self._make_note(root_midi + 12, max(0.0, total_length - 2.0), 0.5, 82), - self._make_note(root_midi + 19, max(0.0, total_length - 1.0), 0.45, 86), - ]) - return notes - - if role == 'impact': - if kind in ['intro', 'outro'] and str(section.get('transition_fill', 'impact')).lower() != 'impact': - return [] - notes = [self._make_note(root_midi + 7, 0.0, 0.5, 82)] - if total_length >= 8.0 and kind in ['build', 'drop']: - notes.append(self._make_note(root_midi + 12, total_length - 0.5, 0.45, 76)) - if kind == 'drop' and total_length >= 16.0 and rng.random() > 0.4: - notes.append(self._make_note(root_midi + 10, 8.0, 0.35, 72)) - return notes - - if role == 'atmos': - notes = [ - self._make_note(root_midi, 0.0, min(8.0, total_length), 54), - self._make_note(root_midi + 7, max(0.0, total_length / 2.0), min(8.0, total_length / 2.0), 50), - ] - if kind in ['intro', 'break', 'outro'] and total_length >= 12.0: - notes.append(self._make_note(root_midi + 12, max(0.0, total_length - 4.0), min(4.0, total_length), 46)) - return notes - - if role == 'vocal': - notes = [] - if kind == 'intro': - base_positions = [7.5, 15.5] - elif kind == 'build': - base_positions = [1.5, 3.5, 5.5, 7.5] - if total_length >= 16.0: - base_positions.extend([11.5, 13.5, 15.5]) - elif kind == 'drop': - base_positions = [1.5, 2.75, 5.5, 6.75] - if total_length >= 16.0: - base_positions.extend([9.5, 10.75, 13.5, 14.75]) - elif kind == 'break': - base_positions = [3.5, 11.5] - else: - base_positions = [1.5, 5.5] - - for index, pos in enumerate(base_positions): - if pos >= total_length: - continue - pitch = root_midi + (10 if kind == 'drop' and index % 2 else 3) - duration = 0.22 if kind == 'drop' else 0.3 - velocity = 80 if kind in ['build', 'drop'] else 72 - if rng.random() > 0.82: - pitch += 12 - notes.append(self._make_note(pitch, pos, duration, velocity)) - - if kind == 'build' and total_length >= 8.0: - notes.append(self._make_note(root_midi + 15, max(0.0, total_length - 0.75), 0.22, 84)) - return notes - - return [] - - def _build_pad_motion(self, key: str, scale_name: str, total_length: float, - octave: int = 4, sustain_beats: float = 4.0) -> List[Dict[str, Any]]: - root_note = key[:-1] if len(key) > 1 else key - root_midi = self.note_name_to_midi(root_note, octave) - scale_notes = self.get_scale_notes(root_midi, scale_name) - progression = random.choice(CHORD_PROGRESSIONS.get('techno' if 'm' in key.lower() else 'house', CHORD_PROGRESSIONS['techno'])) - notes = [] - bars = max(1, int(total_length / 4.0)) - - for bar in range(bars): - degree = progression[bar % len(progression)] - 1 - chord_root = scale_notes[degree % len(scale_notes)] - start = float(bar * 4.0) - duration = min(sustain_beats, total_length - start) - for interval in [0, 7, 12]: - notes.append(self._make_note(chord_root + interval, start, duration, 66)) - return notes - - def _generate_drum_tracks(self, genre: str, style: str) -> List[Dict]: - """Genera tracks de batería""" - tracks = [] - - # Kick siempre - tracks.append({ - 'name': 'Kick', - 'type': 'midi', - 'color': TRACK_COLORS['kick'], - 'clip': { - 'slot': 0, - 'length': 4.0, - 'notes': self._create_kick_pattern(genre, style) - } - }) - - # Snare/Clap - tracks.append({ - 'name': 'Clap', - 'type': 'midi', - 'color': TRACK_COLORS['clap'], - 'clip': { - 'slot': 0, - 'length': 4.0, - 'notes': self._create_clap_pattern(genre, style) - } - }) - - # Hi-hats - tracks.append({ - 'name': 'HiHat', - 'type': 'midi', - 'color': TRACK_COLORS['hat'], - 'clip': { - 'slot': 0, - 'length': 4.0, - 'notes': self._create_hat_pattern(genre, style) - } - }) - - # Percusión extra para estilos más complejos - if style in ['latin', 'afro', 'groovy', 'complex']: - tracks.append({ - 'name': 'Percussion', - 'type': 'midi', - 'color': TRACK_COLORS['hat'], - 'clip': { - 'slot': 0, - 'length': 4.0, - 'notes': self._create_perc_pattern(genre, style) - } - }) - - return tracks - - def _generate_bass_track(self, key: str, scale: str, genre: str, style: str) -> Dict: - """Genera un track de bajo""" - notes = self.create_bassline(key, style, 16.0) - - return { - 'name': 'Bass', - 'type': 'midi', - 'color': TRACK_COLORS['bass'], - 'clip': { - 'slot': 0, - 'length': 16.0, - 'notes': notes - } - } - - def _generate_chord_track(self, key: str, scale: str, genre: str) -> Dict: - """Genera un track de acordes""" - notes = self.create_chord_progression(key, genre, 16.0) - - return { - 'name': 'Chords', - 'type': 'midi', - 'color': TRACK_COLORS['chords'], - 'clip': { - 'slot': 0, - 'length': 16.0, - 'notes': notes - } - } - - def _generate_lead_track(self, key: str, scale: str, genre: str) -> Dict: - """Genera un track lead/melódico""" - notes = self.create_melody(key, scale, 16.0, genre) - - return { - 'name': 'Lead', - 'type': 'midi', - 'color': TRACK_COLORS['synth'], - 'clip': { - 'slot': 0, - 'length': 16.0, - 'notes': notes - } - } - - def _generate_fx_track(self) -> Dict: - """Genera un track de FX/Atmósfera""" - return { - 'name': 'FX', - 'type': 'midi', - 'color': TRACK_COLORS['fx'], - 'clip': { - 'slot': 0, - 'length': 16.0, - 'notes': self._create_fx_notes() - } - } - - # ========================================================================= - # PATRONES DE BATERÍA - # ========================================================================= - - def _create_kick_pattern(self, genre: str, style: str) -> List[Dict]: - """Crea patrón de kick""" - notes = [] - - if style == 'minimal': - # Kick en 1 y 2.5 - for bar in range(4): - notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 120}) - notes.append({'pitch': 36, 'start': bar * 4.0 + 2.5, 'duration': 0.25, 'velocity': 110}) - elif style == 'four-on-the-floor' or genre in ['house', 'tech-house']: - # 4/4 clásico - for bar in range(4): - for beat in range(4): - notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': 127}) - else: # Default techno - for bar in range(4): - for beat in range(4): - vel = 127 if beat == 0 else 115 - notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': vel}) - - return notes - - def _create_clap_pattern(self, genre: str, style: str) -> List[Dict]: - """Crea patrón de clap/snare""" - notes = [] - - # Claps en 2 y 4 (beats 1 y 3 en 0-indexed) - for bar in range(4): - notes.append({'pitch': 40, 'start': bar * 4.0 + 1.0, 'duration': 0.25, 'velocity': 110}) - notes.append({'pitch': 40, 'start': bar * 4.0 + 3.0, 'duration': 0.25, 'velocity': 110}) - - # Snare adicional para DnB/Jungle - if genre == 'drum-and-bass': - for bar in range(4): - notes.append({'pitch': 38, 'start': bar * 4.0 + 1.75, 'duration': 0.1, 'velocity': 90}) - notes.append({'pitch': 38, 'start': bar * 4.0 + 2.25, 'duration': 0.1, 'velocity': 85}) - - return notes - - def _create_hat_pattern(self, genre: str, style: str) -> List[Dict]: - """Crea patrón de hi-hats""" - notes = [] - - if style in ['minimal', 'dub']: - # Off-bats simples - for bar in range(4): - for beat in range(4): - notes.append({'pitch': 42, 'start': bar * 4.0 + beat + 0.5, 'duration': 0.1, 'velocity': 90}) - elif style in ['tech-house-swing', 'jackin', 'swing', 'latin-tech-house']: - # MJ-02: Tech house swing hats - 16% swing on 1/8 notes - swing_offset = 0.04 # ~16% swing at 16th note level - for bar in range(4): - for beat in range(4): - # Straight 8th note - time_straight = bar * 4.0 + beat * 1.0 - notes.append({'pitch': 42, 'start': time_straight, 'duration': 0.1, 'velocity': 95}) - # Swung off-beat 8th - time_off = bar * 4.0 + beat + 0.5 + swing_offset - notes.append({'pitch': 42, 'start': time_off, 'duration': 0.1, 'velocity': 75}) - # Open hat at end of every other bar - if bar % 2 == 1: - notes.append({'pitch': 46, 'start': bar * 4.0 + 3.5, 'duration': 0.4, 'velocity': 80}) - elif style == 'tech-house-jackin': - # MJ-02: Denser hat pattern for jackin tech house - for bar in range(4): - for beat in range(4): - for sub in range(2): - time = bar * 4.0 + beat + sub * 0.5 - vel = 100 if sub == 0 else 80 - notes.append({'pitch': 42, 'start': time, 'duration': 0.08, 'velocity': vel}) - # 16th note fill in last beat - notes.append({'pitch': 42, 'start': bar * 4.0 + 3.75, 'duration': 0.05, 'velocity': 65}) - notes.append({'pitch': 46, 'start': bar * 4.0 + 2.5, 'duration': 0.5, 'velocity': 85}) - elif style == 'tech-house-minimal': - # MJ-02: Sparse, subtle hats for minimal tech house - for bar in range(4): - notes.append({'pitch': 42, 'start': bar * 4.0 + 0.5, 'duration': 0.1, 'velocity': 80}) - notes.append({'pitch': 42, 'start': bar * 4.0 + 2.5, 'duration': 0.1, 'velocity': 70}) - if bar % 2 == 1: - notes.append({'pitch': 46, 'start': bar * 4.0 + 3.5, 'duration': 0.3, 'velocity': 60}) - else: - # 8vos con variación - for bar in range(4): - for beat in range(4): - for sub in range(2): - time = bar * 4.0 + beat + sub * 0.5 - vel = 90 if sub == 0 else 70 - notes.append({'pitch': 42, 'start': time, 'duration': 0.1, 'velocity': vel}) - - # Open hats ocasionales - if style not in ['minimal']: - for bar in range(4): - notes.append({'pitch': 46, 'start': bar * 4.0 + 3.5, 'duration': 0.5, 'velocity': 80}) - - return notes - - def _create_perc_pattern(self, genre: str, style: str) -> List[Dict]: - """Crea patrón de percusión extra""" - notes = [] - - if style in ['latin-tech-house', 'latin', 'latin-industrial']: - # MJ-05: Latin tech house percussion - congas/bongos - for bar in range(4): - # Conga pattern (high conga = pitch 50, low conga = pitch 43) - # Tumbao pattern - notes.append({'pitch': 50, 'start': bar * 4.0 + 0.5, 'duration': 0.15, 'velocity': 85}) - notes.append({'pitch': 50, 'start': bar * 4.0 + 2.5, 'duration': 0.15, 'velocity': 90}) - notes.append({'pitch': 43, 'start': bar * 4.0 + 1.0, 'duration': 0.2, 'velocity': 75}) - notes.append({'pitch': 43, 'start': bar * 4.0 + 3.0, 'duration': 0.2, 'velocity': 80}) - # Bongo accent - if bar % 2 == 0: - notes.append({'pitch': 48, 'start': bar * 4.0 + 1.5, 'duration': 0.1, 'velocity': 70}) - notes.append({'pitch': 48, 'start': bar * 4.0 + 3.5, 'duration': 0.1, 'velocity': 65}) - # Shaker layer - for i in range(8): - time = bar * 4.0 + i * 0.5 - if i % 2 == 1: - notes.append({'pitch': 53, 'start': time, 'duration': 0.05, 'velocity': 50 + random.randint(-5, 5)}) - else: - for bar in range(4): - # Shakers/congas en 16vos - for i in range(16): - time = bar * 4.0 + i * 0.25 - if i % 4 != 0: # Skip downbeats - vel = 60 + random.randint(-10, 10) - notes.append({'pitch': 37, 'start': time, 'duration': 0.1, 'velocity': vel}) - - return notes - - def _create_fx_notes(self) -> List[Dict]: - """Crea notas para FX/atmósfera""" - notes = [] - - # Swells y risers - for bar in [0, 2]: - # Nota larga ascendente - notes.append({'pitch': 84, 'start': bar * 4.0 + 3.0, 'duration': 1.0, 'velocity': 70}) - - return notes - - # ========================================================================= - # CREACIÓN DE PATRONES PARA MCP - # ========================================================================= - - def create_drum_pattern(self, style: str, pattern_type: str, length: float) -> List[Dict]: - """Crea un patrón de batería completo para usar con MCP""" - notes = [] - bars = int(length / 4.0) - - if pattern_type == 'kick-only': - for bar in range(bars): - for beat in range(4): - notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': 127}) - - elif pattern_type == 'hats-only': - for bar in range(bars): - for beat in range(4): - notes.append({'pitch': 42, 'start': bar * 4.0 + beat + 0.5, 'duration': 0.1, 'velocity': 90}) - - elif pattern_type == 'minimal': - for bar in range(bars): - notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 127}) - notes.append({'pitch': 40, 'start': bar * 4.0 + 2.0, 'duration': 0.25, 'velocity': 110}) - notes.append({'pitch': 42, 'start': bar * 4.0 + 2.5, 'duration': 0.1, 'velocity': 80}) - - elif style == 'tech-house-swing': - # MJ-02: Tech house with swing - kick 1&3, ghost kicks on 2.5&3.5, swing hats, ghost clap - for bar in range(bars): - # Main kicks on 1 and 3 - notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 127}) - notes.append({'pitch': 36, 'start': bar * 4.0 + 2.0, 'duration': 0.25, 'velocity': 127}) - # Ghost kicks on 2.5 and 3.5 - notes.append({'pitch': 36, 'start': bar * 4.0 + 1.5, 'duration': 0.15, 'velocity': 95}) - notes.append({'pitch': 36, 'start': bar * 4.0 + 3.5, 'duration': 0.15, 'velocity': 90}) - # Clap on 2 and 4 with ghost note - notes.append({'pitch': 40, 'start': bar * 4.0 + 1.0, 'duration': 0.2, 'velocity': 110}) - notes.append({'pitch': 40, 'start': bar * 4.0 + 1.85, 'duration': 0.08, 'velocity': 60}) - notes.append({'pitch': 40, 'start': bar * 4.0 + 3.0, 'duration': 0.2, 'velocity': 110}) - # Swing hats (16% swing) - swing = 0.04 - for beat in range(4): - notes.append({'pitch': 42, 'start': bar * 4.0 + beat, 'duration': 0.1, 'velocity': 90}) - notes.append({'pitch': 42, 'start': bar * 4.0 + beat + 0.5 + swing, 'duration': 0.1, 'velocity': 70}) - # Open hat - notes.append({'pitch': 46, 'start': bar * 4.0 + 3.5, 'duration': 0.4, 'velocity': 75}) - - elif style == 'tech-house-jackin': - # MJ-02: Jackin tech house - energetic, dense hats, harder clap - for bar in range(bars): - for beat in range(4): - vel = 127 if beat in [0, 2] else 105 - notes.append({'pitch': 36, 'start': bar * 4.0 + beat, 'duration': 0.25, 'velocity': vel}) - # Strong clap on 2 and 4 - notes.append({'pitch': 40, 'start': bar * 4.0 + 1.0, 'duration': 0.2, 'velocity': 120}) - notes.append({'pitch': 40, 'start': bar * 4.0 + 3.0, 'duration': 0.2, 'velocity': 120}) - # Dense 16th hats - for i in range(16): - time = bar * 4.0 + i * 0.25 - vel = 100 if i % 4 == 0 else 75 if i % 2 == 0 else 55 - notes.append({'pitch': 42, 'start': time, 'duration': 0.08, 'velocity': vel}) - # Open hat every bar - notes.append({'pitch': 46, 'start': bar * 4.0 + 2.5, 'duration': 0.5, 'velocity': 85}) - - elif style == 'tech-house-minimal': - # MJ-02: Minimal tech house - sparse kick, subtle perc - for bar in range(bars): - notes.append({'pitch': 36, 'start': bar * 4.0, 'duration': 0.25, 'velocity': 120}) - if bar % 2 == 0: - notes.append({'pitch': 36, 'start': bar * 4.0 + 2.0, 'duration': 0.25, 'velocity': 105}) - # Very sparse hats - notes.append({'pitch': 42, 'start': bar * 4.0 + 1.5, 'duration': 0.08, 'velocity': 65}) - notes.append({'pitch': 42, 'start': bar * 4.0 + 3.5, 'duration': 0.08, 'velocity': 60}) - # Subtle clap on 2 and 4 every other bar - if bar % 2 == 1: - notes.append({'pitch': 40, 'start': bar * 4.0 + 1.0, 'duration': 0.15, 'velocity': 80}) - notes.append({'pitch': 40, 'start': bar * 4.0 + 3.0, 'duration': 0.15, 'velocity': 80}) - - else: # full - notes.extend(self._create_kick_pattern(style, 'standard')) - notes.extend(self._create_clap_pattern(style, 'standard')) - notes.extend(self._create_hat_pattern(style, 'standard')) - - return notes - - return notes - - def create_bassline(self, key: str, style: str, length: float) -> List[Dict]: - """Crea una línea de bajo musical""" - notes = [] - - # Parsear key - root_note = key[:-1] if len(key) > 1 else key - is_minor = 'm' in key.lower() - scale_name = 'minor' if is_minor else 'major' - - root_midi = self.note_name_to_midi(root_note, 2) # Octava 2 para bajo - scale_notes = self.get_scale_notes(root_midi, scale_name) - - bars = int(length / 4.0) - - if style == 'tech-house': - # MJ-03: Tech house bass - syncopated, groovy with velocity variations - for bar in range(bars): - # Beat 1: root on downbeat - notes.append({'pitch': root_midi, 'start': bar * 4.0, 'duration': 0.3, 'velocity': 120}) - # Off-beat after beat 1: fifth or octave - fifth = scale_notes[4] if len(scale_notes) > 4 else root_midi + 7 - notes.append({'pitch': fifth, 'start': bar * 4.0 + 0.75, 'duration': 0.2, 'velocity': 85}) - # Beat 2: syncopated - skip beat 2, play on 2.5 - notes.append({'pitch': root_midi, 'start': bar * 4.0 + 2.25, 'duration': 0.25, 'velocity': 95}) - # Beat 3: root again - notes.append({'pitch': root_midi, 'start': bar * 4.0 + 3.0, 'duration': 0.3, 'velocity': 110}) - # Off-beat ghost - minor_third = scale_notes[2] if len(scale_notes) > 2 else root_midi + 3 - notes.append({'pitch': minor_third, 'start': bar * 4.0 + 3.5, 'duration': 0.15, 'velocity': 70}) - # Bar variation: every 2nd bar add extra syncopation - if bar % 2 == 1: - notes.append({'pitch': root_midi, 'start': bar * 4.0 + 1.5, 'duration': 0.15, 'velocity': 80}) - - elif style == 'rolling': - # Bass en 16vos - for bar in range(bars): - for beat in range(4): - for sub in range(4): - time = bar * 4.0 + beat + sub * 0.25 - if sub == 0: - pitch = root_midi - vel = 120 - elif sub == 2: - pitch = scale_notes[4] if len(scale_notes) > 4 else root_midi + 7 - vel = 100 - else: - pitch = root_midi - vel = 80 if sub % 2 == 0 else 70 - - notes.append({'pitch': pitch, 'start': time, 'duration': 0.2, 'velocity': vel}) - - elif style == 'minimal': - # Solo en beats 1 y 3 - for bar in range(bars): - for beat in [0, 2]: - time = bar * 4.0 + beat - notes.append({'pitch': root_midi, 'start': time, 'duration': 1.5, 'velocity': 110}) - - elif style == 'offbeat': - # Notas en off-beats (house típico) - for bar in range(bars): - for beat in range(4): - time = bar * 4.0 + beat + 0.5 - pitch = root_midi if beat % 2 == 0 else scale_notes[3] - notes.append({'pitch': pitch, 'start': time, 'duration': 0.4, 'velocity': 100}) - - elif style == 'acid': - # Estilo TB-303 con slides - for bar in range(bars): - for i in range(8): - time = bar * 4.0 + i * 0.5 - pitch = root_midi + random.choice([0, 3, 5, 7, 10]) - vel = 90 + random.randint(-20, 20) - notes.append({'pitch': pitch, 'start': time, 'duration': 0.4, 'velocity': min(127, max(60, vel))}) - - else: # walking - for bar in range(bars): - for beat in range(4): - time = bar * 4.0 + beat - if beat == 0: - pitch = root_midi - elif beat == 1: - pitch = scale_notes[2] if len(scale_notes) > 2 else root_midi + 3 - elif beat == 2: - pitch = scale_notes[3] if len(scale_notes) > 3 else root_midi + 5 - else: - pitch = scale_notes[4] if len(scale_notes) > 4 else root_midi + 7 - - notes.append({'pitch': pitch, 'start': time, 'duration': 0.9, 'velocity': 100}) - - return notes - - def create_chord_progression(self, key: str, progression_type: str, length: float) -> List[Dict]: - """Crea una progresión de acordes""" - notes = [] - - # Parsear key - root_note = key[:-1] if len(key) > 1 else key - is_minor = 'm' in key.lower() - scale_name = 'minor' if is_minor else 'major' - - root_midi = self.note_name_to_midi(root_note, 4) # Octava 4 para acordes - scale_notes = self.get_scale_notes(root_midi, scale_name) - - # Seleccionar progresión - progressions = CHORD_PROGRESSIONS.get(progression_type, CHORD_PROGRESSIONS['techno']) - progression = random.choice(progressions) - - bars = int(length / 4.0) - beats_per_bar = 4 - - for bar in range(bars): - degree = progression[bar % len(progression)] - 1 - - if degree < len(scale_notes): - chord_root = scale_notes[degree] - else: - chord_root = root_midi - - # Construir acorde (triada) - third = 3 if 'minor' in scale_name else 4 - chord_tones = [chord_root, chord_root + third, chord_root + 7] - - # Stab chords - cortos y percusivos - if progression_type == 'techno': - for pitch in chord_tones: - notes.append({ - 'pitch': pitch, - 'start': bar * beats_per_bar, - 'duration': 0.25, - 'velocity': 90 - }) - elif progression_type == 'house': - for beat in [0.5, 2.5]: - for pitch in chord_tones: - notes.append({ - 'pitch': pitch, - 'start': bar * beats_per_bar + beat, - 'duration': 0.5, - 'velocity': 75 - }) - else: - # Default: acordes en beats 1 y 3 - for beat in [0, 2]: - for pitch in chord_tones: - notes.append({ - 'pitch': pitch, - 'start': bar * beats_per_bar + beat, - 'duration': 1.0, - 'velocity': 85 - }) - - return notes - - def create_melody(self, key: str, scale: str, length: float, genre: str) -> List[Dict]: - """Crea una melodía/lead""" - notes = [] - - root_note = key[:-1] if len(key) > 1 else key - root_midi = self.note_name_to_midi(root_note, 5) # Octava 5 para lead - scale_notes = self.get_scale_notes(root_midi, scale) - - bars = max(1, int(length / 4.0)) - motif_pool = [ - ([0, 2, 4, 2, 5, 4], [0.0, 0.5, 1.5, 2.0, 2.75, 3.25]), - ([0, 3, 4, 6, 4], [0.0, 0.75, 1.5, 2.5, 3.25]), - ([0, 2, 3, 5, 3, 2], [0.0, 0.5, 1.0, 2.0, 2.5, 3.5]), - ] - motif_steps, motif_times = random.choice(motif_pool) - - for bar in range(bars): - bar_offset = bar * 4.0 - phrase_shift = 0 if bar % 4 in [0, 1] else random.choice([0, 1, -1, 2]) - invert_tail = (bar % 4 == 3) - for index, step in enumerate(motif_steps): - start = bar_offset + motif_times[index % len(motif_times)] - if start >= length: - continue - if invert_tail and index >= max(1, len(motif_steps) - 2): - start += 0.25 - if random.random() < 0.18 and index not in [0, len(motif_steps) - 1]: - continue - - scale_index = (step + phrase_shift) % len(scale_notes) - pitch = scale_notes[scale_index] - if genre in ['trance', 'progressive'] and index == len(motif_steps) - 1: - pitch += 12 - elif genre in ['techno', 'tech-house'] and index % 3 == 2: - pitch -= 12 - - duration = 0.22 if start % 1.0 not in [0.0, 0.5] else 0.35 - velocity = 78 + ((index + bar) % 3) * 8 + random.randint(-6, 8) - notes.append({ - 'pitch': pitch, - 'start': start, - 'duration': duration, - 'velocity': max(60, min(123, velocity)) - }) - - return notes - - # ========================================================================= - # Human Feel Integration - # ========================================================================= - - def apply_human_feel(self, config: Dict[str, Any], intensity: float = 0.6) -> Dict[str, Any]: - """ - Aplica herramientas de human feel a una configuración generada. - - Args: - config: Configuración del generador - intensity: Intensidad (0.3=sutil, 0.6=groove, 1.0=vivo) - - Returns: - Configuración con human feel aplicado - - Configuración por intensidad: - - 0.3 (sutil): Fades rápidos en intro/outro, LFO ±1.5dB, sparse fills, 8% swing - - 0.6 (groove): Fades en tutti, LFO ±3dB con S-curve, medium fills, 14% swing - - 1.0 (vivo): Fades agresivos con pump, LFO ±5dB, heavy fills, 18% swing - """ - import random - - # Configuración por intensidad - intensity_config = { - 0.3: { - 'fade_in_bars': 2.0, - 'fade_out_bars': 2.0, - 'lfo_depth': 1.5, - 'lfo_rate': 0.25, - 'fill_density': 'sparse', - 'swing_percent': 8.0, - 'sidechain_style': 'subtle', - }, - 0.6: { - 'fade_in_bars': 4.0, - 'fade_out_bars': 4.0, - 'lfo_depth': 3.0, - 'lfo_rate': 0.5, - 'fill_density': 'medium', - 'swing_percent': 14.0, - 'sidechain_style': 'jackin', - }, - 1.0: { - 'fade_in_bars': 6.0, - 'fade_out_bars': 6.0, - 'lfo_depth': 5.0, - 'lfo_rate': 0.75, - 'fill_density': 'heavy', - 'swing_percent': 18.0, - 'sidechain_style': 'jackin', - }, - } - - cfg = intensity_config.get(intensity, intensity_config[0.6]) - - # Aplicar swing a patrones MIDI - for track in config.get('tracks', []): - if track.get('type') == 'midi': - # Aplicar swing al pattern - pattern = track.get('pattern', []) - if pattern: - track['swing_percent'] = cfg['swing_percent'] - - # Agregar automation de volumen por sección - for track in config.get('tracks', []): - role = track.get('role', '') - if role in ['kick', 'bass', 'top_loop', 'synth_loop']: - # Automatización de volumen por sección - track['volume_automation'] = { - 'curve_type': 's_curve' if intensity >= 0.6 else 'linear', - 'section_map': { - 'intro': 0.5 + (0.35 * intensity), - 'build': 0.7 + (0.15 * intensity), - 'drop': 0.85 + (0.1 * intensity), - 'break': 0.6 + (0.2 * intensity), - 'outro': 0.4 + (0.2 * intensity), - } - } - - # Agregar sidechain pump para buses - if 'buses' in config: - for bus_name, bus in config['buses'].items(): - if bus_name in ['drums', 'bass', 'music']: - bus['sidechain_pump'] = { - 'intensity': intensity, - 'style': cfg['sidechain_style'], - 'attack': 0.001 * (1.0 / intensity), - 'release': 0.1 * intensity, - } - - # Agregar fills automáticos - for track in config.get('tracks', []): - if track.get('type') == 'midi' and track.get('role') in ['kick', 'snare', 'hat']: - track['pattern_fills'] = { - 'density': cfg['fill_density'], - 'section': 'all', - 'fill_probability': 0.125 * (1 + intensity), # 1 cada 8-4 bars - } - - # Agregar variación de loops por sección - for track in config.get('tracks', []): - if track.get('type') == 'audio' and track.get('role'): - track['loop_variation'] = { - 'intro': 'filtered' if intensity >= 0.3 else 'standard', - 'build': 'building' if intensity >= 0.6 else 'standard', - 'drop': 'full' if intensity >= 0.6 else 'standard', - 'break': 'sparse' if intensity >= 0.3 else 'standard', - 'outro': 'fading' if intensity >= 0.6 else 'standard', - } - - return config - - def generate_with_human_feel(self, genre: str, style: str, bpm: float, key: str, - structure: str, intensity: float = 0.6) -> Dict[str, Any]: - """ - Genera una configuración completa con human feel aplicado. - - Args: - genre: Género musical - style: Sub-estilo - bpm: BPM - key: Tonalidad - structure: Estructura ('standard', 'club', 'tech-house-dj') - intensity: Intensidad de human feel (0.3-1.0) - - Returns: - Configuración completa con human feel - """ - # Generar configuración base - config = self.generate_config(genre, style, bpm, key, structure) - - # Aplicar human feel - config = self.apply_human_feel(config, intensity) - - return config - diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/template_analyzer.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/template_analyzer.py deleted file mode 100644 index b1823d2..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/template_analyzer.py +++ /dev/null @@ -1,177 +0,0 @@ -from __future__ import annotations - -import argparse -import gzip -import json -from collections import Counter -from pathlib import Path -import xml.etree.ElementTree as ET - - -def _node_name(node: ET.Element | None) -> str: - if node is None: - return "" - for tag in ("EffectiveName", "UserName", "Name"): - child = node.find(tag) - if child is not None: - value = child.attrib.get("Value", "") - if value: - return value - return node.attrib.get("Value", "") - - -def _device_name(device: ET.Element) -> str: - if device.tag == "PluginDevice": - info = device.find("PluginDesc/VstPluginInfo") - if info is None: - info = device.find("PluginDesc/AuPluginInfo") - if info is not None: - plug = info.find("PlugName") - if plug is not None and plug.attrib.get("Value"): - return plug.attrib["Value"] - return device.tag - - -def _session_clip_count(track: ET.Element) -> int: - count = 0 - for slot in track.findall("./DeviceChain/MainSequencer/ClipSlotList/ClipSlot"): - if slot.find("Value/MidiClip") is not None or slot.find("Value/AudioClip") is not None: - count += 1 - return count - - -def _arrangement_clip_count(track: ET.Element) -> int: - return len(track.findall(".//MainSequencer//MidiClip")) + len( - track.findall(".//MainSequencer//AudioClip") - ) - - -def _tempo_value(live_set: ET.Element) -> float | None: - node = live_set.find(".//Tempo/Manual") - if node is None: - return None - try: - return float(node.attrib.get("Value", "0")) - except ValueError: - return None - - -def _locator_summary(live_set: ET.Element) -> list[dict[str, float | str | None]]: - locators: list[tuple[float, str]] = [] - for locator in live_set.findall(".//Locators/Locators/Locator"): - try: - time = float(locator.find("Time").attrib.get("Value", "0")) - except (AttributeError, ValueError): - time = 0.0 - name = _node_name(locator.find("Name")) - locators.append((time, name)) - locators.sort(key=lambda item: item[0]) - summary: list[dict[str, float | str | None]] = [] - for index, (time, name) in enumerate(locators): - next_time = locators[index + 1][0] if index + 1 < len(locators) else None - summary.append( - { - "time_beats": time, - "name": name, - "section_length_beats": None if next_time is None else next_time - time, - } - ) - return summary - - -def _arrangement_length_beats(root: ET.Element) -> float: - max_end = 0.0 - for clip in root.findall(".//MidiClip") + root.findall(".//AudioClip"): - current_end = clip.find("CurrentEnd") - start = clip.attrib.get("Time") - if current_end is None or start is None: - continue - try: - end = float(start) + float(current_end.attrib.get("Value", "0")) - except ValueError: - continue - max_end = max(max_end, end) - return max_end - - -def analyze_set(als_path: Path) -> dict: - with gzip.open(als_path, "rb") as handle: - root = ET.parse(handle).getroot() - live_set = root.find("LiveSet") - if live_set is None: - raise ValueError(f"Invalid ALS file: {als_path}") - - tracks = list(live_set.find("Tracks") or []) - track_summaries = [] - device_counter: Counter[str] = Counter() - - for track in tracks: - devices = track.findall("./DeviceChain/DeviceChain/Devices/*") - device_names = [_device_name(device) for device in devices] - device_counter.update(device_names) - track_summaries.append( - { - "type": track.tag, - "name": _node_name(track.find("Name")), - "group_id": track.find("TrackGroupId").attrib.get("Value", "") - if track.find("TrackGroupId") is not None - else "", - "session_clip_count": _session_clip_count(track), - "arrangement_clip_count": _arrangement_clip_count(track), - "devices": device_names, - } - ) - - automation_events = 0 - for automation in root.findall(".//ArrangerAutomation"): - automation_events += len(automation.findall(".//FloatEvent")) - automation_events += len(automation.findall(".//EnumEvent")) - automation_events += len(automation.findall(".//BoolEvent")) - - return { - "file": str(als_path), - "tempo": _tempo_value(live_set), - "track_type_counts": dict(Counter(track.tag for track in tracks)), - "scene_count": len(live_set.findall("./SceneNames/Scene")), - "locators": _locator_summary(live_set), - "arrangement_length_beats": _arrangement_length_beats(root), - "automation_event_count": automation_events, - "top_devices": dict(device_counter.most_common(16)), - "tracks": track_summaries, - } - - -def main() -> None: - parser = argparse.ArgumentParser(description="Analyze Ableton .als templates.") - parser.add_argument("path", nargs="?", default=".", help="Folder containing .als files") - parser.add_argument("--json", action="store_true", help="Emit JSON") - args = parser.parse_args() - - base = Path(args.path).resolve() - results = [analyze_set(path) for path in sorted(base.rglob("*.als"))] - - if args.json: - print(json.dumps(results, indent=2)) - return - - for result in results: - print(f"=== {Path(result['file']).name} ===") - print(f"tempo: {result['tempo']}") - print(f"tracks: {result['track_type_counts']}") - print(f"scenes: {result['scene_count']}") - print(f"arrangement_length_beats: {result['arrangement_length_beats']}") - print(f"automation_event_count: {result['automation_event_count']}") - print("locators:") - for locator in result["locators"]: - print( - f" - {locator['time_beats']:>6} {locator['name']}" - f" len={locator['section_length_beats']}" - ) - print("top_devices:") - for name, count in result["top_devices"].items(): - print(f" - {name}: {count}") - print() - - -if __name__ == "__main__": - main() diff --git a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/vector_manager.py b/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/vector_manager.py deleted file mode 100644 index 99572c2..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/MCP_Server/vector_manager.py +++ /dev/null @@ -1,452 +0,0 @@ -import os -import json -import logging -import argparse -from pathlib import Path -from typing import List, Dict, Tuple, Optional -from multiprocessing import Pool, cpu_count -import functools - -try: - from sentence_transformers import SentenceTransformer - from sklearn.metrics.pairwise import cosine_similarity - import numpy as np - HAS_ML = True -except ImportError: - HAS_ML = False - -# Import AudioAnalyzer for spectral analysis -try: - from audio_analyzer import AudioAnalyzer, analyze_sample - HAS_AUDIO_ANALYZER = True -except ImportError: - HAS_AUDIO_ANALYZER = False - -logger = logging.getLogger("VectorManager") -logging.basicConfig(level=logging.INFO) - - -# Global analyzer for multiprocessing workers (initialized once per worker) -_worker_analyzer = None - -def _init_worker(): - """Initialize the audio analyzer for each worker process.""" - global _worker_analyzer - if HAS_AUDIO_ANALYZER: - try: - _worker_analyzer = AudioAnalyzer(backend="auto") - except Exception: - _worker_analyzer = None - -def _process_single_file(args): - """ - Process a single audio file and return its metadata. - Used for multiprocessing parallel execution. - """ - f, library_dir, skip_audio_analysis = args - f = Path(f) - - import soundfile as sf - - # Clean up the name for better semantic understanding - name = f.stem - name_lower = name.lower() - clean_name = name.replace('_', ' ').replace('-', ' ').lower() - - # Keywords that strongly suggest a full song/mix - full_song_keywords = {'original mix', 'extended mix', 'full mix', 'edit', 'master', '320kbps', 'remix'} - - # Extract duration - duration = 0.0 - try: - info = sf.info(str(f)) - duration = info.duration - except Exception: - duration = -1.0 - - # Detect if it's likely a full song based on name and duration - is_full_song = False - if duration > 45.0: - is_full_song = True - elif any(kw in name_lower for kw in full_song_keywords) and duration > 30.0: - is_full_song = True - - # Spectral analysis with AudioAnalyzer - key = None - key_confidence = 0.0 - spectral_centroid = None - is_harmonic = None - - global _worker_analyzer - if not skip_audio_analysis and _worker_analyzer is not None: - try: - features = _worker_analyzer.analyze(str(f)) - key = features.key - key_confidence = features.key_confidence - spectral_centroid = features.spectral_centroid - is_harmonic = features.is_harmonic - except Exception: - pass - - # Use relative path as part of the context - try: - rel_path = f.relative_to(library_dir) - parts = rel_path.parts[:-1] - path_context = " ".join(parts).lower() - except ValueError: - path_context = "" - - description = f"{clean_name} {path_context}" - - metadata = { - 'path': str(f), - 'name': name, - 'description': description, - 'duration': duration, - 'is_full_song': is_full_song, - 'key': key, - 'key_confidence': key_confidence, - 'spectral_centroid': spectral_centroid, - 'is_harmonic': is_harmonic - } - - return metadata, description - -class VectorManager: - def __init__(self, library_dir: str, skip_audio_analysis: bool = False): - self.library_dir = Path(library_dir) - self.index_file = self.library_dir / ".sample_embeddings.json" - self.skip_audio_analysis = skip_audio_analysis - - self.model = None - self.embeddings = [] - self.metadata = [] - - # Audio analyzer instance for spectral analysis - self._audio_analyzer: Optional[AudioAnalyzer] = None - if HAS_AUDIO_ANALYZER and not skip_audio_analysis: - try: - self._audio_analyzer = AudioAnalyzer(backend="auto") - logger.info("AudioAnalyzer initialized for spectral analysis") - except Exception as e: - logger.warning(f"Failed to initialize AudioAnalyzer: {e}") - self._audio_analyzer = None - - if HAS_ML: - try: - # Load a very lightweight model for fast embeddings - logger.info("Loading sentence-transformers model (all-MiniLM-L6-v2)...") - self.model = SentenceTransformer('all-MiniLM-L6-v2') - except Exception as e: - logger.error(f"Failed to load embedding model: {e}") - - self._load_or_build_index() - - def _get_library_fingerprint(self) -> Dict: - """Compute a fingerprint of the library directory for change detection (BF-02/MJ-07).""" - extensions = {'.wav', '.aif', '.aiff', '.mp3'} - file_count = 0 - latest_mtime = 0.0 - try: - for ext in extensions: - for f in self.library_dir.rglob('*' + ext): - file_count += 1 - try: - mtime = f.stat().st_mtime - if mtime > latest_mtime: - latest_mtime = mtime - except OSError: - pass - for f in self.library_dir.rglob('*' + ext.upper()): - file_count += 1 - try: - mtime = f.stat().st_mtime - if mtime > latest_mtime: - latest_mtime = mtime - except OSError: - pass - except Exception: - pass - return {'file_count': file_count, 'latest_mtime': latest_mtime} - - def _load_or_build_index(self): - if self.index_file.exists(): - logger.info("Loading existing vector index...") - try: - with open(self.index_file, 'r', encoding='utf-8') as f: - data = json.load(f) - self.metadata = data.get('metadata', []) - - # BF-02/MJ-07: Check library fingerprint for auto-rebuild - stored_fp = data.get('library_fingerprint', {}) - current_fp = self._get_library_fingerprint() - stored_count = stored_fp.get('file_count', 0) - current_count = current_fp.get('file_count', 0) - if current_count != stored_count and stored_count > 0: - logger.info(f"Library changed ({stored_count} -> {current_count} files). Rebuilding index...") - self._build_index() - return - - if HAS_ML and 'embeddings' in data: - self.embeddings = np.array(data['embeddings']) - else: - logger.warning("No embeddings found in loaded index.") - except Exception as e: - logger.error(f"Failed to load index: {e}") - self._build_index() - else: - self._build_index() - - def _build_index(self): - logger.info(f"Scanning library {self.library_dir} for new embeddings...") - extensions = {'.wav', '.aif', '.aiff', '.mp3'} - - files_to_process = [] - for ext in extensions: - files_to_process.extend(self.library_dir.rglob('*' + ext)) - files_to_process.extend(self.library_dir.rglob('*' + ext.upper())) - - if not files_to_process: - logger.warning(f"No audio files found in {self.library_dir} to embed.") - return - - # Get unique files - unique_files = list(set(str(f) for f in files_to_process)) - total_files = len(unique_files) - logger.info(f"Found {total_files} audio files to process") - - # Determine number of workers (use 50% of available CPUs) - num_workers = max(1, cpu_count() // 2) - logger.info(f"Using {num_workers} CPU cores for parallel processing (50% capacity)") - - # Prepare arguments for parallel processing - args_list = [(f, str(self.library_dir), self.skip_audio_analysis) for f in unique_files] - - # Process files in parallel using multiprocessing - texts_to_embed = [] - self.metadata = [] - - if not self.skip_audio_analysis and HAS_AUDIO_ANALYZER: - # Use multiprocessing with audio analysis - logger.info("Starting parallel audio analysis...") - with Pool(processes=num_workers, initializer=_init_worker) as pool: - results = pool.map(_process_single_file, args_list) - - for metadata, description in results: - self.metadata.append(metadata) - texts_to_embed.append(description) - else: - # Fallback to sequential processing (no audio analysis) - logger.info("Processing files sequentially (audio analysis disabled)...") - import soundfile as sf - full_song_keywords = {'original mix', 'extended mix', 'full mix', 'edit', 'master', '320kbps', 'remix'} - - for i, f in enumerate(unique_files): - f = Path(f) - if (i + 1) % max(1, total_files // 20) == 0 or (i + 1) == total_files: - logger.info(f"Processing files: {i+1}/{total_files} ({(i+1)/total_files*100:.1f}%)") - - name = f.stem - clean_name = name.replace('_', ' ').replace('-', ' ').lower() - - duration = 0.0 - try: - info = sf.info(str(f)) - duration = info.duration - except Exception: - duration = -1.0 - - is_full_song = duration > 45.0 - - try: - rel_path = f.relative_to(self.library_dir) - path_context = " ".join(rel_path.parts[:-1]).lower() - except ValueError: - path_context = "" - - description = f"{clean_name} {path_context}" - texts_to_embed.append(description) - - self.metadata.append({ - 'path': str(f), - 'name': name, - 'description': description, - 'duration': duration, - 'is_full_song': is_full_song, - 'key': None, - 'key_confidence': 0.0, - 'spectral_centroid': None, - 'is_harmonic': None - }) - - if HAS_ML and self.model: - logger.info(f"Generating vectors for {len(texts_to_embed)} samples. This might take a moment...") - embeddings = self.model.encode(texts_to_embed) - self.embeddings = embeddings - - # BF-02: Save fingerprint alongside embeddings for auto-rebuild detection - fingerprint = self._get_library_fingerprint() - - # Save the vectors - with open(self.index_file, 'w', encoding='utf-8') as f: - json.dump({ - 'metadata': self.metadata, - 'embeddings': embeddings.tolist(), - 'library_fingerprint': fingerprint - }, f) - logger.info(f"Saved {len(self.metadata)} embeddings to {self.index_file}.") - else: - logger.error("ML libraries not installed. Run 'pip install sentence-transformers scikit-learn numpy'") - - # MJ-06: Genre keyword expansion for richer semantic search - GENRE_SEARCH_TERMS = { - 'tech-house': ['groovy', 'driving', 'punchy', 'jackin', 'swinging', 'hypnotic', 'bouncy'], - 'house': ['deep', 'soulful', 'warm', 'classic', 'funky'], - 'techno': ['industrial', 'dark', 'raw', 'hypnotic', 'peak-time', 'acid'], - 'trance': ['uplifting', 'ethereal', 'driving', 'euphoric'], - 'deep-house': ['deep', 'chill', 'smooth', 'laidback', 'warm'], - 'minimal': ['minimal', 'sparse', 'subtle', 'clean'], - 'drum-and-bass': ['heavy', 'dark', 'neuro', 'rolling', 'aggressive'], - } - - def enrich_query_with_genre(self, query: str, genre: str = "") -> str: - """MJ-06: Enrich a search query with genre-specific terms.""" - genre_lower = (genre or "").lower().strip() - terms = self.GENRE_SEARCH_TERMS.get(genre_lower, []) - if terms: - # Pick 2 random genre terms to enrich without overwhelming - import random as _rng - picked = _rng.sample(terms, min(2, len(terms))) - enriched = f"{query} {' '.join(picked)}" - logger.info(f"Enriched query for '{genre_lower}': '{query}' -> '{enriched}'") - return enriched - return query - - def semantic_search(self, query: str, limit: int = 5, max_duration: float = 0.0, genre: str = "") -> List[Dict]: - """ - Returns a list of metadata dicts sorted by semantic relevance down to the limit. - Fallback to basic substring matching if ML is unavailable. - - Args: - query: Semantic search terms - limit: Max results to return - max_duration: If > 0, filter out samples longer than this value - genre: Optional genre to enrich the search query (MJ-06) - """ - if not HAS_ML or self.model is None or len(self.embeddings) == 0: - logger.warning("ML unavailable, falling back to substring search.") - return self._fallback_search(query, limit, max_duration) - - # MJ-06: Enrich query with genre terms - effective_query = self.enrich_query_with_genre(query, genre) if genre else query - - logger.info(f"Performing semantic search for: '{effective_query}' (max_duration={max_duration})") - query_emb = self.model.encode([effective_query]) - - # Calculate cosine similarity between query and all stored embeddings - similarities = cosine_similarity(query_emb, self.embeddings)[0] - - # Apply duration and full-song penalties/filtering - adjusted_similarities = similarities.copy() - - for i, meta in enumerate(self.metadata): - # Filter out if it exceeds max_duration (if specified) - if max_duration > 0 and (meta.get('duration', 0) > max_duration or meta.get('duration', 0) < 0): - adjusted_similarities[i] = -1.0 - continue - - # Filter out explicit full songs - if meta.get('is_full_song', False) and max_duration > 0: - adjusted_similarities[i] = -1.0 - continue - - # Small penalty for longer samples if no max_duration specified - # to prioritize snippets over loops - if max_duration == 0 and meta.get('duration', 0) > 10.0: - adjusted_similarities[i] *= 0.9 - - # Get top indices from adjusted scores - top_indices = np.argsort(adjusted_similarities)[::-1][:limit] - - results = [] - for idx in top_indices: - score = float(adjusted_similarities[idx]) - if score < 0: # All remaining candidates are invalid - break - - meta = self.metadata[idx].copy() - meta['score'] = score - results.append(meta) - - return results - - def _fallback_search(self, query: str, limit: int = 5, max_duration: float = 0.0) -> List[Dict]: - query = query.lower() - scored = [] - for m in self.metadata: - # Duration filter - if max_duration > 0 and (m.get('duration', 0) > max_duration or m.get('duration', 0) < 0): - continue - if m.get('is_full_song', False) and max_duration > 0: - continue - - score = 0 - if query in m['name'].lower(): - score += 10 - if query in m['description'].lower(): - score += 5 - - if score > 0: - scored.append((score, m)) - - scored.sort(key=lambda x: x[0], reverse=True) - return [m for s, m in scored[:limit]] - -if __name__ == "__main__": - import sys - import argparse - - parser = argparse.ArgumentParser(description="Vector Manager for sample library indexing") - parser.add_argument("library_dir", nargs='?', help="Path to the sample library directory") - parser.add_argument("search_query", nargs='?', help="Optional search query to test") - parser.add_argument("--skip-audio-analysis", action="store_true", - help="Skip spectral audio analysis for faster rebuild (development mode)") - parser.add_argument("--rebuild", action="store_true", - help="Force rebuild of the index from scratch") - - args = parser.parse_args() - - if args.library_dir: - # Check if index exists and rebuild flag is set - index_file = Path(args.library_dir) / ".sample_embeddings.json" - if args.rebuild and index_file.exists(): - logger.info(f"Removing existing index for rebuild: {index_file}") - index_file.unlink() - - vm = VectorManager(args.library_dir, skip_audio_analysis=args.skip_audio_analysis) - - if args.search_query: - res = vm.semantic_search(args.search_query) - print(f"Search Results for '{args.search_query}':") - for r in res: - print(f" Score: {r['score']:.3f}") - print(f" Name: {r['name']}") - print(f" Path: {r['path']}") - print(f" Key: {r.get('key', 'N/A')} (confidence: {r.get('key_confidence', 0):.2f})") - print(f" Spectral Centroid: {r.get('spectral_centroid', 'N/A')}") - print(f" Is Harmonic: {r.get('is_harmonic', 'N/A')}") - print() - else: - # Print summary of the loaded index - print(f"\nIndex Summary:") - print(f" Total samples: {len(vm.metadata)}") - # Count samples with spectral data - with_key = sum(1 for m in vm.metadata if m.get('key') is not None) - with_centroid = sum(1 for m in vm.metadata if m.get('spectral_centroid') is not None) - print(f" Samples with key detected: {with_key}") - print(f" Samples with spectral centroid: {with_centroid}") - else: - print("Usage: python vector_manager.py [search_query] [--skip-audio-analysis] [--rebuild]") - print("\nOptions:") - print(" --skip-audio-analysis Skip spectral analysis for faster rebuild") - print(" --rebuild Force rebuild index from scratch") diff --git a/AbletonMCP_AI_BAK_20260328_200801/README.md b/AbletonMCP_AI_BAK_20260328_200801/README.md deleted file mode 100644 index e5570dc..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/README.md +++ /dev/null @@ -1,222 +0,0 @@ -# AbletonMCP-AI - -Sistema hibrido para controlar Ableton Live 12 desde MCP y generar proyectos musicales complejos, orientados a Arrangement View. - -Combina: - -- un Remote Script dentro de Live -- un servidor MCP en Python (52+ tools) -- seleccion de samples desde biblioteca local con busqueda semantica ML -- reconstruccion guiada por referencias -- fallback de audio y capas MIDI/instrumentos -- buses, returns y snapshots de mezcla por seccion -- mezcla harmonica Camelot wheel para DJ sets -- generacion Tech House DJ-ready con intro/outro extendidas - -Esta es la snapshot del proyecto al 2026-03-28. - -## Estado actual - -El sistema ya puede: - -- generar proyectos completos en Arrangement View con samples de la biblioteca local -- crear estructura, tracks, scenes, cue points y guide track -- combinar MIDI, instrumentos stock y audio de biblioteca local (827 samples indexados) -- analizar un track de referencia y reconstruir un resultado original inspirado en ese material -- materializar capas `AUDIO ...` con samples reales (kick, bass, synth, vocal, FX, etc.) -- aplicar snapshots por seccion a tracks y returns durante el commit Session -> Arrangement -- operar con returns desde el runtime y desde el MCP -- buses de mezcla (DRUM BUS, BASS BUS, MUSIC WIDE, VOCAL BUS, FX WASH) -- capas derivadas (RESAMPLE REVERSE FX, RISER, DOWNLIFTER, STUTTER) -- generar estructuras DJ-ready con intro/outro de 32 compases para beatmatching -- mezcla harmonica con Camelot wheel (compatible keys, sugerencias de transicion) -- auto-descubrir tracks de referencia desde `librerias/reference/` -- previsualizar blueprints sin crear nada en Ableton -- regenerar secciones individuales -- persistir historia de generaciones y diversidad de samples entre sesiones -- busqueda semantica enriquecida por genero (tech-house, house, techno, trance, etc.) -- auto-reindexar la biblioteca cuando cambian los archivos -- validar automaticamente el set post-generacion - -## Arquitectura resumida - -1. `__init__.py` - Remote Script principal. Vive dentro de Ableton, abre el socket TCP y ejecuta comandos sobre la API de Live. -2. `MCP_Server/server.py` - Servidor MCP/FastMCP. Expone tools, normaliza aliases y habla con el Remote Script. -3. `MCP_Server/song_generator.py` - Generador musical. Construye blueprint de tracks, sections, performance, locators y returns. -4. `MCP_Server/reference_listener.py` - Escucha el audio de referencia y arma un plan de reconstruccion usando la biblioteca local. -5. `MCP_Server/sample_manager.py`, `sample_selector.py`, `audio_analyzer.py` - Indexado, busqueda, scoring y analisis de samples. -6. `MaxForLive/` - Devices `.amxd` para la ruta hibrida con M4L. - -## Layout del repo - -```text -AbletonMCP_AI/ -|-- __init__.py -|-- Remote_Script.py -|-- start_server.bat -|-- .mcp.json -|-- README.md -|-- CLAUDE.md -|-- MaxForLive/ -| |-- AbletonMCP_Engine.amxd -| |-- AbletonMCP_Engine.maxpat -| `-- AbletonMCP_SamplerPro.amxd -|-- MCP_Server/ -| |-- server.py -| |-- song_generator.py -| |-- reference_listener.py -| |-- audio_analyzer.py -| |-- sample_manager.py -| |-- sample_selector.py -| |-- sample_index.py -| |-- socket_smoke_test.py -| |-- template_analyzer.py -| |-- ABLETUNES_TEMPLATE_NOTES.md -| `-- requirements.txt -`-- docs/ - |-- AI_HANDOFF.md - |-- ARCHITECTURE.md - |-- GPU_DIRECTML.md - |-- MCP_TOOLS.md - |-- PROJECT_CONTEXT.md - |-- REMOTE_PROTOCOL.md - `-- SETUP_WINDOWS.md -``` - -## Documentacion - -Leer primero: - -- [CLAUDE.md](CLAUDE.md) - handoff amplio, cronologia completa, estado real, paths y notas operativas -- [AI_HANDOFF](docs/AI_HANDOFF.md) - handoff corto y operativo -- [PROJECT_CONTEXT](docs/PROJECT_CONTEXT.md) - direccion de producto y lecciones aprendidas - -- [Arquitectura](docs/ARCHITECTURE.md) -- [Setup en Windows + Ableton](docs/SETUP_WINDOWS.md) -- [Tools MCP](docs/MCP_TOOLS.md) -- [Protocolo del Remote Script](docs/REMOTE_PROTOCOL.md) -- [GPU DirectML](docs/GPU_DIRECTML.md) -- [Notas del analisis de templates Abletunes](MCP_Server/ABLETUNES_TEMPLATE_NOTES.md) - -## Quick start - -### 1. Clonar y colocar en la carpeta de Ableton - -```powershell -# Clonar el repo -git clone https://gitea.cbcren.online/renato97/ableton-mcp-ai.git - -# Copiar a la carpeta de MIDI Remote Scripts -cp -r ableton-mcp-ai "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI" -``` - -### 2. Instalar dependencias Python - -```powershell -cd "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server" -python -m pip install -r requirements.txt -``` - -### 3. Seleccionar el Control Surface en Live - -- Abrir Ableton Live 12. -- Ir a `Preferences > Link/Tempo/MIDI`. -- Elegir `AbletonMCP_AI` como `Control Surface`. - -### 4. Levantar el servidor MCP - -```powershell -cd "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI" -python MCP_Server/server.py -``` - -O: - -```powershell -start_server.bat -``` - -### 5. Probar conexion - -```powershell -cd "C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\MCP_Server" -python socket_smoke_test.py -``` - -## Ubicaciones externas esperadas - -Este repo no incluye bibliotecas pesadas ni material generado. El stack espera estos recursos fuera del repo: - -- biblioteca principal de samples (organizada por categorias): - `..\librerias\organized_samples` (827 samples indexados) -- biblioteca raw original: - `..\librerias\all_tracks` -- vector store para matching ML: - `..\librerias\vector_store` -- Ableton User Library para instalar el sampler M4L: - `%USERPROFILE%\Documents\Ableton\User Library` -- referencias MP3/WAV que se quieran analizar: - `..\sample` -- proyectos `.als`, renders y stems - -## Flujo recomendado - -1. Resetear el set. -2. Generar un track desde MCP o por socket. -3. Validar que el commit termine en Arrangement View. -4. Revisar audio tracks `AUDIO ...` y returns. -5. Ajustar perfiles, matching y snapshots. - -## Comandos utiles - -Generacion completa: - -```text -generate_track(genre="tech-house", style="latin-industrial", bpm=0, key="", structure="standard") -generate_track(genre="tech-house", style="groovy", bpm=126, key="Am", structure="tech-house-dj") -generate_song(genre="tech-house", style="latin-industrial", bpm=0, key="", structure="club") -``` - -DJ / Harmonic mixing: - -```text -get_harmonic_keys(key="Am") -get_compatible_keys(key="Am") -export_stems_config() -discover_reference_track() -get_reference_suggestions() -``` - -Utilidades de generacion: - -```text -preview_generation(genre="tech-house", style="groovy", bpm=126, key="Am", structure="tech-house-dj") -regenerate_section(section_name="DROP A") -get_generation_history() -``` - -Transporte: - -```text -start_playback() -stop_playback() -set_tempo(126) -``` - -Samples: - -```text -search_samples("kick", category="kick", limit=10) -advanced_search_samples(query="vocal", category="vocals", bpm=128, key="F#m") -analyze_audio_file("C:\\ruta\\track.mp3") -``` - -## Licencia - -Sin licencia publicada por ahora. Tratar este repo como privado/interno hasta definirla. diff --git a/AbletonMCP_AI_BAK_20260328_200801/Remote_Script.py b/AbletonMCP_AI_BAK_20260328_200801/Remote_Script.py deleted file mode 100644 index d78f91c..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/Remote_Script.py +++ /dev/null @@ -1,943 +0,0 @@ -""" -AbletonMCP AI - Remote Script para Ableton Live 12 -Integración completa con MCP para generación musical por IA - -Este script debe copiarse a: -C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\ - -Y luego seleccionarse en Preferencias > Link/Tempo/MIDI > Control Surface -""" -from __future__ import absolute_import, print_function, unicode_literals - -from _Framework.ControlSurface import ControlSurface -import socket -import json -import threading -import time -import traceback -import os -import hashlib - -# Python 2/3 compatibility -try: - import queue -except ImportError: - pass - -try: - string_types = basestring -except NameError: - string_types = str - -# Configuración -DEFAULT_PORT = 9877 -HOST = "localhost" -CONFIG_FILE = r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts\AbletonMCP_AI\track_config.json" - - -def create_instance(c_instance): - """Crea y retorna la instancia del script""" - return AbletonMCP_AI(c_instance) - - -class AbletonMCP_AI(ControlSurface): - """ - Remote Script para integración MCP + AI con Ableton Live 12 - - Características: - - Servidor socket para comunicación con MCP Server - - Generación de tracks MIDI con patrones automáticos - - Carga de samples vía browser - - Integración con análisis de audio por IA - """ - - def __init__(self, c_instance): - ControlSurface.__init__(self, c_instance) - self.log_message("=" * 60) - self.log_message("AbletonMCP AI - Inicializando...") - self.log_message("=" * 60) - - # Referencia a la canción - self._song = self.song() - - # Servidor socket - self.server = None - self.client_threads = [] - self.server_thread = None - self.running = False - - # Config watcher para generación automática - self._last_config_hash = None - self._config_watcher_thread = None - self._config_watcher_running = False - - # Iniciar servidor - self.start_server() - - # Iniciar watcher de configuración - self.start_config_watcher() - - self.log_message("AbletonMCP AI inicializado correctamente") - self.show_message("AbletonMCP AI: Listo en puerto " + str(DEFAULT_PORT)) - - def disconnect(self): - """Llamado cuando Ableton cierra o se remueve el script""" - self.log_message("AbletonMCP AI desconectando...") - self.running = False - self._config_watcher_running = False - - # Detener servidor - if self.server: - try: - self.server.close() - except Exception: - pass - - # Esperar threads - if self.server_thread and self.server_thread.is_alive(): - self.server_thread.join(1.0) - - if self._config_watcher_thread and self._config_watcher_thread.is_alive(): - self._config_watcher_thread.join(0.5) - - ControlSurface.disconnect(self) - self.log_message("AbletonMCP AI desconectado") - - # ========================================================================= - # SERVIDOR SOCKET - # ========================================================================= - - def start_server(self): - """Inicia el servidor socket en un thread separado""" - try: - self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.server.bind((HOST, DEFAULT_PORT)) - self.server.listen(5) - - self.running = True - self.server_thread = threading.Thread(target=self._server_thread) - self.server_thread.daemon = True - self.server_thread.start() - - self.log_message("Servidor socket iniciado en puerto " + str(DEFAULT_PORT)) - except Exception as e: - self.log_message("Error iniciando servidor: " + str(e)) - self.show_message("AbletonMCP AI Error: " + str(e)) - - def _server_thread(self): - """Thread principal del servidor - maneja conexiones""" - try: - self.server.settimeout(1.0) - - while self.running: - try: - client, address = self.server.accept() - self.log_message("Conexión aceptada de " + str(address)) - - # Manejar cliente en thread separado - client_thread = threading.Thread( - target=self._handle_client, - args=(client,) - ) - client_thread.daemon = True - client_thread.start() - - self.client_threads.append(client_thread) - - # Limpiar threads terminados - self.client_threads = [t for t in self.client_threads if t.is_alive()] - - except socket.timeout: - continue - except Exception as e: - if self.running: - self.log_message("Error servidor: " + str(e)) - time.sleep(0.5) - - except Exception as e: - self.log_message("Error thread servidor: " + str(e)) - - def _handle_client(self, client): - """Maneja comunicación con un cliente conectado""" - client.settimeout(None) - buffer = '' - - try: - while self.running: - try: - data = client.recv(8192) - - if not data: - self.log_message("Cliente desconectado") - break - - # Acumular en buffer - try: - buffer += data.decode('utf-8') - except AttributeError: - buffer += data - - # Intentar parsear JSON - try: - command = json.loads(buffer) - buffer = '' - - self.log_message("Comando recibido: " + str(command.get("type", "unknown"))) - - # Procesar comando - response = self._process_command(command) - - # Enviar respuesta - try: - client.sendall(json.dumps(response).encode('utf-8')) - except AttributeError: - client.sendall(json.dumps(response)) - - except ValueError: - # Datos incompletos, esperar más - continue - - except Exception as e: - self.log_message("Error manejando cliente: " + str(e)) - error_response = {"status": "error", "message": str(e)} - try: - client.sendall(json.dumps(error_response).encode('utf-8')) - except Exception: - pass - break - - finally: - try: - client.close() - except Exception: - pass - - # ========================================================================= - # CONFIG WATCHER - Generación automática - # ========================================================================= - - def start_config_watcher(self): - """Inicia el watcher de configuración para generación automática""" - self._config_watcher_running = True - self._config_watcher_thread = threading.Thread(target=self._config_watcher_loop) - self._config_watcher_thread.daemon = True - self._config_watcher_thread.start() - self.log_message("Config watcher iniciado") - - def _config_watcher_loop(self): - """Loop que monitorea cambios en el archivo de configuración""" - while self._config_watcher_running: - try: - if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, 'r') as f: - content = f.read() - - h = hashlib.md5(content.encode()).hexdigest() - if h != self._last_config_hash: - self._last_config_hash = h - self.log_message("Config cambiado - generando track...") - - try: - config = json.loads(content) - # Solo procesar si tiene flag 'auto_generate' - if config.get('auto_generate', False): - self._generate_from_config(config) - except Exception as e: - self.log_message("Error generando desde config: " + str(e)) - self.log_message(traceback.format_exc()) - - time.sleep(1.0) # Revisar cada segundo - - except Exception as e: - self.log_message("Error en config watcher: " + str(e)) - time.sleep(2.0) - - def _generate_from_config(self, config): - """Genera un track completo desde una configuración""" - try: - self.show_message("AI: Generando " + config.get('name', 'Track')) - - # 1. Limpiar proyecto existente - self._clear_all_tracks() - - # 2. Setear BPM - bpm = config.get('bpm', 128) - self._song.tempo = bpm - - # 3. Crear tracks según configuración - tracks_config = config.get('tracks', []) - - for idx, track_cfg in enumerate(tracks_config): - track_type = track_cfg.get('type', 'midi') - name = track_cfg.get('name', 'Track ' + str(idx)) - - if track_type == 'midi': - self._song.create_midi_track(idx) - elif track_type == 'audio': - self._song.create_audio_track(idx) - - track = self._song.tracks[idx] - track.name = name - - # Setear color si existe - if 'color' in track_cfg: - track.color = track_cfg['color'] - - # Crear clip con notas si existe configuración - if 'clip' in track_cfg: - clip_cfg = track_cfg['clip'] - slot_idx = clip_cfg.get('slot', 0) - length = clip_cfg.get('length', 4.0) - - # Asegurar que existan suficientes scenes - while len(self._song.scenes) <= slot_idx: - self._song.create_scene(-1) - - clip_slot = track.clip_slots[slot_idx] - clip_slot.create_clip(length) - - # Agregar notas - if 'notes' in clip_cfg: - clip = clip_slot.clip - for note in clip_cfg['notes']: - pitch = note.get('pitch', 60) - start = note.get('start', 0.0) - duration = note.get('duration', 0.25) - velocity = note.get('velocity', 100) - clip.add_new_note((pitch, start, duration, velocity, False)) - - # Cargar instrumento si se especifica - if 'instrument' in track_cfg: - instrument_name = track_cfg['instrument'] - # Usar browser para cargar - self._load_instrument_by_name(track, instrument_name) - - self.show_message("AI: Track generado exitosamente!") - self.log_message("Generación completada: " + str(len(tracks_config)) + " tracks") - - except Exception as e: - self.log_message("Error en generación: " + str(e)) - self.log_message(traceback.format_exc()) - self.show_message("AI Error: " + str(e)) - - def _clear_all_tracks(self): - """Elimina todos los tracks existentes""" - try: - while len(self._song.tracks) > 0: - self._song.delete_track(len(self._song.tracks) - 1) - except Exception as e: - self.log_message("Error limpiando tracks: " + str(e)) - - def _load_instrument_by_name(self, track, name): - """Carga un instrumento en el track por nombre""" - try: - browser = self.application().browser - - # Buscar en categorías de instrumentos - if hasattr(browser, 'instruments'): - for item in self._search_browser_items(browser.instruments, name): - try: - browser.load_item(item) - self.log_message("Instrumento cargado: " + name) - return True - except Exception as e: - self.log_message("Error cargando instrumento: " + str(e)) - - return False - except Exception as e: - self.log_message("Error buscando instrumento: " + str(e)) - return False - - def _search_browser_items(self, root, name, depth=0, max_depth=5): - """Busca items en el browser recursivamente""" - if depth > max_depth or root is None: - return [] - - results = [] - try: - # Verificar si el nombre coincide - item_name = getattr(root, 'name', '').lower() - if name.lower() in item_name or item_name in name.lower(): - results.append(root) - - # Buscar en hijos - if hasattr(root, 'children'): - for child in root.children: - results.extend(self._search_browser_items(child, name, depth + 1, max_depth)) - except Exception: - pass - - return results - - # ========================================================================= - # PROCESAMIENTO DE COMANDOS - # ========================================================================= - - def _process_command(self, command): - """Procesa un comando recibido y retorna respuesta""" - command_type = command.get("type", "") - params = command.get("params", {}) - - try: - # Comandos de información - if command_type == "get_session_info": - return self._cmd_get_session_info() - - elif command_type == "get_track_info": - return self._cmd_get_track_info(params) - - elif command_type == "get_tracks": - return self._cmd_get_tracks() - - # Comandos de tracks - elif command_type == "create_midi_track": - return self._cmd_create_midi_track(params) - - elif command_type == "create_audio_track": - return self._cmd_create_audio_track(params) - - elif command_type == "set_track_name": - return self._cmd_set_track_name(params) - - elif command_type == "set_track_volume": - return self._cmd_set_track_volume(params) - - elif command_type == "set_track_pan": - return self._cmd_set_track_pan(params) - - elif command_type == "set_track_mute": - return self._cmd_set_track_mute(params) - - elif command_type == "set_track_solo": - return self._cmd_set_track_solo(params) - - elif command_type == "set_track_color": - return self._cmd_set_track_color(params) - - # Comandos de clips - elif command_type == "create_clip": - return self._cmd_create_clip(params) - - elif command_type == "add_notes_to_clip": - return self._cmd_add_notes_to_clip(params) - - elif command_type == "set_clip_name": - return self._cmd_set_clip_name(params) - - elif command_type == "set_clip_envelope": - return self._cmd_set_clip_envelope(params) - - elif command_type == "fire_clip": - return self._cmd_fire_clip(params) - - elif command_type == "stop_clip": - return self._cmd_stop_clip(params) - - # Comandos de transporte - elif command_type == "set_tempo": - return self._cmd_set_tempo(params) - - elif command_type == "start_playback": - return self._cmd_start_playback() - - elif command_type == "stop_playback": - return self._cmd_stop_playback() - - # Comandos de escenas - elif command_type == "create_scene": - return self._cmd_create_scene(params) - - elif command_type == "set_scene_name": - return self._cmd_set_scene_name(params) - - elif command_type == "fire_scene": - return self._cmd_fire_scene(params) - - # Comandos de dispositivos - elif command_type == "load_instrument_or_effect": - return self._cmd_load_instrument(params) - - elif command_type == "set_device_parameter": - return self._cmd_set_device_parameter(params) - - # Comando de generación AI - elif command_type == "generate_track": - return self._cmd_generate_track(params) - - else: - return {"status": "error", "message": "Comando desconocido: " + command_type} - - except Exception as e: - self.log_message("Error procesando comando " + command_type + ": " + str(e)) - self.log_message(traceback.format_exc()) - return {"status": "error", "message": str(e)} - - # ========================================================================= - # IMPLEMENTACIÓN DE COMANDOS - # ========================================================================= - - def _cmd_get_session_info(self): - """Retorna información de la sesión actual""" - return { - "status": "success", - "result": { - "tempo": self._song.tempo, - "signature_numerator": self._song.signature_numerator, - "signature_denominator": self._song.signature_denominator, - "is_playing": self._song.is_playing, - "current_song_time": self._song.current_song_time, - "loop_start": self._song.loop_start, - "loop_length": self._song.loop_length, - "num_tracks": len(self._song.tracks), - "num_scenes": len(self._song.scenes), - "num_return_tracks": len(self._song.return_tracks) - } - } - - def _cmd_get_track_info(self, params): - """Retorna información de un track específico""" - idx = params.get("track_index", 0) - if idx < 0 or idx >= len(self._song.tracks): - return {"status": "error", "message": "Track index fuera de rango"} - - track = self._song.tracks[idx] - - # Determinar tipo de track - track_type = "unknown" - if track.has_midi_input: - track_type = "midi" - elif track.has_audio_input: - track_type = "audio" - - return { - "status": "success", - "result": { - "index": idx, - "name": track.name, - "type": track_type, - "color": track.color, - "mute": track.mute, - "solo": track.solo, - "arm": track.arm, - "volume": track.mixer_device.volume.value if track.mixer_device else 0.85, - "pan": track.mixer_device.panning.value if track.mixer_device else 0.0, - "num_clips": len(track.clip_slots), - "num_devices": len(track.devices) - } - } - - def _cmd_get_tracks(self): - """Retorna lista de todos los tracks""" - tracks = [] - for i, track in enumerate(self._song.tracks): - track_type = "midi" if track.has_midi_input else "audio" if track.has_audio_input else "unknown" - tracks.append({ - "index": i, - "name": track.name, - "type": track_type, - "color": track.color, - "mute": track.mute, - "solo": track.solo - }) - - return {"status": "success", "result": tracks} - - def _cmd_create_midi_track(self, params): - """Crea un track MIDI""" - index = params.get("index", -1) - self._song.create_midi_track(index) - return {"status": "success", "result": {"message": "MIDI track creado", "index": index}} - - def _cmd_create_audio_track(self, params): - """Crea un track de audio""" - index = params.get("index", -1) - self._song.create_audio_track(index) - return {"status": "success", "result": {"message": "Audio track creado", "index": index}} - - def _cmd_set_track_name(self, params): - """Setea el nombre de un track""" - idx = params.get("track_index", 0) - name = params.get("name", "Track") - self._song.tracks[idx].name = name - return {"status": "success", "result": {"message": "Nombre actualizado", "name": name}} - - def _cmd_set_track_volume(self, params): - """Setea el volumen de un track""" - idx = params.get("track_index", 0) - volume = params.get("volume", 0.85) - track = self._song.tracks[idx] - if track.mixer_device and track.mixer_device.volume: - track.mixer_device.volume.value = volume - return {"status": "success"} - - def _cmd_set_track_pan(self, params): - """Setea el pan de un track""" - idx = params.get("track_index", 0) - pan = params.get("pan", 0.0) - track = self._song.tracks[idx] - if track.mixer_device and track.mixer_device.panning: - track.mixer_device.panning.value = pan - return {"status": "success"} - - def _cmd_set_track_mute(self, params): - """Setea el mute de un track""" - idx = params.get("track_index", 0) - mute = params.get("mute", True) - track = self._song.tracks[idx] - current_mute = track.mute - if current_mute != mute: - track.mute = mute - return {"status": "success", "result": {"mute": track.mute, "track_index": idx}} - - def _cmd_set_track_solo(self, params): - """Setea el solo de un track""" - idx = params.get("track_index", 0) - solo = params.get("solo", True) - self._song.tracks[idx].solo = solo - return {"status": "success"} - - def _cmd_set_track_color(self, params): - """Setea el color de un track""" - idx = params.get("track_index", 0) - color = params.get("color", 0) - self._song.tracks[idx].color = color - return {"status": "success"} - - def _cmd_create_clip(self, params): - """Crea un clip en un slot""" - track_idx = params.get("track_index", 0) - clip_idx = params.get("clip_index", 0) - length = params.get("length", 4.0) - - track = self._song.tracks[track_idx] - - # Asegurar que existan suficientes scenes - while len(self._song.scenes) <= clip_idx: - self._song.create_scene(-1) - - clip_slot = track.clip_slots[clip_idx] - clip_slot.create_clip(length) - - return {"status": "success", "result": {"message": "Clip creado"}} - - def _cmd_add_notes_to_clip(self, params): - """Agrega notas a un clip MIDI""" - track_idx = params.get("track_index", 0) - clip_idx = params.get("clip_index", 0) - notes = params.get("notes", []) - - track = self._song.tracks[track_idx] - clip_slot = track.clip_slots[clip_idx] - - if not clip_slot.has_clip: - return {"status": "error", "message": "No hay clip en este slot"} - - clip = clip_slot.clip - - for note in notes: - pitch = note.get("pitch", 60) - start = note.get("start", 0.0) - duration = note.get("duration", 0.25) - velocity = note.get("velocity", 100) - clip.add_new_note((pitch, start, duration, velocity, False)) - - return {"status": "success", "result": {"num_notes_added": len(notes)}} - - def _cmd_set_clip_name(self, params): - """Setea el nombre de un clip""" - track_idx = params.get("track_index", 0) - clip_idx = params.get("clip_index", 0) - name = params.get("name", "Clip") - - clip_slot = self._song.tracks[track_idx].clip_slots[clip_idx] - if clip_slot.has_clip: - clip_slot.clip.name = name - - return {"status": "success"} - - def _cmd_fire_clip(self, params): - """Dispara un clip""" - track_idx = params.get("track_index", 0) - clip_idx = params.get("clip_index", 0) - - clip_slot = self._song.tracks[track_idx].clip_slots[clip_idx] - clip_slot.fire() - - return {"status": "success"} - - def _cmd_stop_clip(self, params): - """Detiene un clip""" - track_idx = params.get("track_index", 0) - clip_idx = params.get("clip_index", 0) - - clip_slot = self._song.tracks[track_idx].clip_slots[clip_idx] - clip_slot.stop() - - return {"status": "success"} - - def _cmd_set_tempo(self, params): - """Setea el BPM""" - tempo = params.get("tempo", 120.0) - self._song.tempo = tempo - return {"status": "success", "result": {"tempo": tempo}} - - def _cmd_start_playback(self): - """Inicia reproducción""" - self._song.start_playing() - return {"status": "success"} - - def _cmd_stop_playback(self): - """Detiene reproducción""" - self._song.stop_playing() - return {"status": "success"} - - def _cmd_create_scene(self, params): - """Crea una scene""" - index = params.get("index", -1) - self._song.create_scene(index) - return {"status": "success"} - - def _cmd_set_scene_name(self, params): - """Setea el nombre de una scene""" - idx = params.get("scene_index", 0) - name = params.get("name", "Scene") - self._song.scenes[idx].name = name - return {"status": "success"} - - def _cmd_fire_scene(self, params): - """Dispara una scene""" - idx = params.get("scene_index", 0) - scene = self._song.scenes[idx] - scene.fire() - - if not self._song.is_playing: - self._song.start_playing() - - return {"status": "success"} - - def _cmd_load_instrument(self, params): - """Carga un instrumento en un track""" - track_idx = params.get("track_index", 0) - name = params.get("name", "") - - track = self._song.tracks[track_idx] - success = self._load_instrument_by_name(track, name) - - if success: - return {"status": "success", "result": {"message": "Instrumento cargado"}} - else: - return {"status": "error", "message": "No se pudo cargar el instrumento"} - - def _cmd_set_device_parameter(self, params): - """Setea un parámetro de dispositivo""" - track_idx = params.get("track_index", 0) - device_idx = params.get("device_index", 0) - param_idx = params.get("parameter_index", 0) - value = params.get("value", 0.0) - - track = self._song.tracks[track_idx] - device = track.devices[device_idx] - param = device.parameters[param_idx] - param.value = value - - return {"status": "success"} - - def _cmd_generate_track(self, params): - """Comando principal de generación de tracks""" - # Este comando delega a _generate_from_config - # pero puede ser llamado directamente vía socket - try: - self._generate_from_config(params) - return {"status": "success", "result": {"message": "Track generado exitosamente"}} - except Exception as e: - return {"status": "error", "message": str(e)} - - def _cmd_set_clip_envelope(self, params): - """Setea un envelope (volume, pan, send) en un clip con puntos de automatización""" - track_idx = params.get("track_index", 0) - clip_idx = params.get("clip_index", 0) - envelope_name = params.get("envelope", "volume") # volume, pan, send - points = params.get("points", []) - - track = self._song.tracks[track_idx] - clip_slot = track.clip_slots[clip_idx] - - if not clip_slot.has_clip: - return {"status": "error", "message": "No hay clip en este slot"} - - clip = clip_slot.clip - - # Obtener el envelope correcto - if envelope_name == "volume": - envelope = clip.volume_envelope - elif envelope_name == "pan": - envelope = clip.pan_envelope - elif envelope_name == "send": - send_idx = params.get("send_index", 0) - if send_idx < len(track.mixer_device.sends): - envelope = track.mixer_device.sends[send_idx].envelope - else: - return {"status": "error", "message": "Send index fuera de rango"} - else: - return {"status": "error", "message": "Envelope type desconocido: " + envelope_name} - - # Limpiar puntos existentes si se especifica - clear_existing = params.get("clear_existing", False) - if clear_existing: - while len(envelope.points) > 0: - envelope.delete_point(len(envelope.points) - 1) - - # Agregar puntos de automatización desde el array de puntos - if points: - for point in points: - if isinstance(point, dict): - time_pos = point.get("time", 0.0) - value = point.get("value", 0.0) - envelope.add_new_point(time_pos, value) - return {"status": "success", "result": {"message": "Envelope seteado con puntos", "points_added": len(points)}} - else: - return {"status": "error", "message": "No se especificaron puntos de automatización"} - - def _cmd_calibrate_track_gain(self, params): - """Calibra el gain de un track basado en loudness""" - track_idx = params.get("track_index", 0) - target_loudness = params.get("target_loudness", -14.0) # LUFS target - measurement_window = params.get("measurement_window", 0.1) # segundos - - track = self._song.tracks[track_idx] - if not track.has_audio_input: - return {"status": "error", "message": "Track no es de audio"} - - # Obtener el peak volume actual - current_volume = track.mixer_device.volume.value - - # Calibrar para alcanzar el target (simplificado) - # En una implementación real, usaríamos análisis de loudness real - # Por ahora, ajustamos proporcionalmente - adjustment = target_loudness / -20.0 # Aproximación - new_volume = max(0.0, min(1.0, current_volume * adjustment)) - - track.mixer_device.volume.value = new_volume - - return { - "status": "success", - "result": { - "message": "Gain calibrado", - "current_volume": current_volume, - "new_volume": new_volume, - "target_loudness": target_loudness - } - } - - def _cmd_apply_compression(self, params): - """Aplica compresión a un track""" - track_idx = params.get("track_index", 0) - threshold = params.get("threshold", -24.0) - ratio = params.get("ratio", 4.0) - attack = params.get("attack", 0.01) - release = params.get("release", 0.1) - - track = self._song.tracks[track_idx] - - # Buscar o crear compressor - compressor = None - for device in track.devices: - if device.name == "Compressor": - compressor = device - break - - if compressor is None: - # Intentar cargar Compressor desde browser - browser = self.application().browser - for item in self._search_browser_items(browser.effects, "Compressor"): - try: - browser.load_item(item) - compressor = track.devices[-1] - break - except Exception: - pass - - if compressor: - # Setear parámetros (índices pueden variar según versión) - try: - if len(compressor.parameters) > 0: - compressor.parameters[0].value = threshold # Threshold - if len(compressor.parameters) > 1: - compressor.parameters[1].value = ratio # Ratio - if len(compressor.parameters) > 2: - compressor.parameters[2].value = attack # Attack - if len(compressor.parameters) > 3: - compressor.parameters[3].value = release # Release - except Exception: - pass - - return {"status": "success", "result": {"message": "Compresor aplicado"}} - else: - return {"status": "error", "message": "No se pudo cargar compresor"} - - def _cmd_apply_limiting(self, params): - """Aplica limiting para loudness normalization""" - track_idx = params.get("track_index", 0) - target_loudness = params.get("target_loudness", -1.0) # LUFS para master - lookahead = params.get("lookahead", 0.01) - release = params.get("release", 0.05) - - track = self._song.tracks[track_idx] - - # Buscar o crear limiter - limiter = None - for device in track.devices: - if "Limiter" in device.name: - limiter = device - break - - if limiter is None: - # Intentar cargar Limiter desde browser - browser = self.application().browser - for item in self._search_browser_items(browser.effects, "Limiter"): - try: - browser.load_item(item) - limiter = track.devices[-1] - break - except Exception: - pass - - if limiter: - # Setear parámetros - try: - if len(limiter.parameters) > 0: - limiter.parameters[0].value = target_loudness # Gain - if len(limiter.parameters) > 1: - limiter.parameters[1].value = lookahead # Lookahead - if len(limiter.parameters) > 2: - limiter.parameters[2].value = release # Release - except Exception: - pass - - return {"status": "success", "result": {"message": "Limiter aplicado"}} - else: - return {"status": "error", "message": "No se pudo cargar limiter"} - - def _cmd_master_loudness_normalization(self, params): - """Normaliza el loudness del master track""" - track_idx = params.get("track_index", 0) - target_loudness = params.get("target_loudness", -14.0) - - track = self._song.tracks[track_idx] - - # Calibrar gain - current_volume = track.mixer_device.volume.value - adjustment = 10 ** ((target_loudness - (-14)) / 20) # Aproximación - new_volume = max(0.0, min(1.0, current_volume * adjustment)) - - track.mixer_device.volume.value = new_volume - - return { - "status": "success", - "result": { - "message": "Loudness normalizado", - "target_loudness": target_loudness, - "new_volume": new_volume - } - } diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.example.json b/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.example.json deleted file mode 100644 index 285238e..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.example.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "implementer": { - "description": "Implements the requested code changes with minimal diff.", - "prompt": "You are a focused implementation worker. Make the requested code changes, keep the diff small, and do not overclaim." - }, - "verifier": { - "description": "Runs validations and checks whether the claimed work is actually complete.", - "prompt": "You are a strict verifier. Run the requested validations, compare code against claims, and report gaps clearly." - }, - "reporter": { - "description": "Writes the final worker report truthfully.", - "prompt": "You are a truthful technical reporter. Summarize only what was actually changed and verified." - } -} diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.team.json b/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.team.json deleted file mode 100644 index 65aac2f..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/glm_agents.team.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "planner": { - "description": "Breaks the task into a small, realistic execution plan and identifies the critical path.", - "prompt": "You are the planning agent. Read the task, identify the minimum safe plan, and tell the team what to implement first. Keep the plan concrete and short." - }, - "implementer_core": { - "description": "Implements the main code changes with a minimal diff.", - "prompt": "You are the core implementation agent. Make the requested code changes with the smallest coherent diff. Do not overclaim." - }, - "implementer_aux": { - "description": "Implements helper scripts, manifests, reports, and offline tooling.", - "prompt": "You are the auxiliary implementation agent. Focus on CLI helpers, manifests, reports, and utility scripts. Keep changes isolated." - }, - "validator": { - "description": "Runs validations and checks whether the implementation actually works.", - "prompt": "You are the validation agent. Run the required validations, inspect failures carefully, and report only what really passed." - }, - "retrieval_reviewer": { - "description": "Reviews retrieval/indexing logic for role contamination, cache compatibility, and data-shape issues.", - "prompt": "You are the retrieval reviewer. Inspect role safety, cache compatibility, manifests, and offline retrieval quality. Flag contamination and schema mismatches." - }, - "runtime_guard": { - "description": "Protects the Ableton runtime and blocks risky unrelated changes.", - "prompt": "You are the runtime guard. Prevent unnecessary edits to the Remote Script, runtime socket behavior, or generation path when the task does not require it." - }, - "reporter": { - "description": "Writes the final task report truthfully and concisely.", - "prompt": "You are the reporting agent. Write a technical report that only claims what was truly changed and verified." - } -} diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/invoke_codex_review.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/invoke_codex_review.ps1 deleted file mode 100644 index f34456c..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/invoke_codex_review.ps1 +++ /dev/null @@ -1,94 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$TaskFile, - - [Parameter(Mandatory = $true)] - [string]$ReportFile, - - [Parameter(Mandatory = $true)] - [string]$ProjectRoot, - - [Parameter(Mandatory = $true)] - [string]$OutputFile, - - [string]$CodexModel = "" -) - -$ErrorActionPreference = "Stop" - -function Resolve-CodexCommand() { - $cmd = Get-Command "codex.cmd" -ErrorAction SilentlyContinue - if ($cmd) { - return $cmd.Source - } - - $fallback = Get-Command "codex" -ErrorAction SilentlyContinue - if ($fallback) { - return $fallback.Source - } - - throw "Command not found: codex" -} - -$taskPath = (Resolve-Path -LiteralPath $TaskFile).Path -$reportPath = (Resolve-Path -LiteralPath $ReportFile).Path -$projectPath = (Resolve-Path -LiteralPath $ProjectRoot).Path -$outputPath = [System.IO.Path]::GetFullPath($OutputFile) -$codexCommand = Resolve-CodexCommand - -$reviewPrompt = @" -Read this worker task file: -$taskPath - -Read this GLM report: -$reportPath - -Your job: -1. Inspect the real diff in the repository. -2. Verify whether GLM actually implemented what the report claims. -3. Fix anything incorrect, incomplete, or unsafe. -4. Run the relevant validations mentioned by the task/report. -5. Leave the repository in the best corrected state you can reach in one pass. -6. Write a concise final summary to the output file configured by the CLI. - -Be strict about overclaims. The code is the source of truth, not the report. -"@ - -$codexArgs = @( - "exec", - "--dangerously-bypass-approvals-and-sandbox", - "-C", $projectPath, - "-o", $outputPath -) - -if (-not [string]::IsNullOrWhiteSpace($CodexModel)) { - $codexArgs += @("-m", $CodexModel) -} - -$codexArgs += $reviewPrompt - -$stdoutPath = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($outputPath), "codex_review_stdout.tmp.txt") -$stderrPath = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($outputPath), "codex_review_stderr.tmp.txt") - -if (Test-Path -LiteralPath $stdoutPath) { Remove-Item -LiteralPath $stdoutPath -Force } -if (Test-Path -LiteralPath $stderrPath) { Remove-Item -LiteralPath $stderrPath -Force } - -Push-Location $projectPath -try { - & $codexCommand @codexArgs 1> $stdoutPath 2> $stderrPath - $exitCode = $LASTEXITCODE -} -finally { - Pop-Location -} - -if (Test-Path -LiteralPath $stdoutPath) { - Get-Content -LiteralPath $stdoutPath -} -if (Test-Path -LiteralPath $stderrPath) { - Get-Content -LiteralPath $stderrPath -} - -if ($exitCode -ne 0) { - throw "Codex exited with code $exitCode" -} diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/reports/glm_task_001_benchmark_check.json b/AbletonMCP_AI_BAK_20260328_200801/automation/reports/glm_task_001_benchmark_check.json deleted file mode 100644 index 216df61..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/reports/glm_task_001_benchmark_check.json +++ /dev/null @@ -1,401 +0,0 @@ -{ - "benchmark_info": { - "library_dir": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks", - "top_n": 3, - "roles": [ - "kick", - "snare", - "hat", - "bass_loop", - "vocal_loop", - "top_loop" - ], - "timestamp": "2026-03-20T16:36:16", - "device": "directml" - }, - "references": [ - { - "file_name": "Mr. Pauer, Goyo - Química (Video Oficial).mp3", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\sample\\Mr. Pauer, Goyo - Química (Video Oficial).mp3", - "analysis_time_seconds": 3.09, - "reference_info": { - "tempo": 123.047, - "key": "Cm", - "duration": 145.31, - "rms_mean": 0.17201, - "onset_mean": 1.956218, - "spectral_centroid": 2465.478 - }, - "sections": [ - { - "kind": "verse", - "start": 0.0, - "end": 14.954, - "bars": 8 - }, - { - "kind": "build", - "start": 14.954, - "end": 37.779, - "bars": 12 - }, - { - "kind": "verse", - "start": 37.779, - "end": 46.811, - "bars": 5 - }, - { - "kind": "verse", - "start": 46.811, - "end": 54.822, - "bars": 4 - }, - { - "kind": "drop", - "start": 54.822, - "end": 62.833, - "bars": 4 - }, - { - "kind": "build", - "start": 62.833, - "end": 70.844, - "bars": 4 - }, - { - "kind": "verse", - "start": 70.844, - "end": 92.415, - "bars": 11 - }, - { - "kind": "build", - "start": 92.415, - "end": 101.03, - "bars": 4 - }, - { - "kind": "verse", - "start": 101.03, - "end": 109.041, - "bars": 4 - }, - { - "kind": "build", - "start": 109.041, - "end": 117.098, - "bars": 4 - }, - { - "kind": "outro", - "start": 117.098, - "end": 125.109, - "bars": 4 - }, - { - "kind": "outro", - "start": 125.109, - "end": 133.422, - "bars": 4 - }, - { - "kind": "outro", - "start": 133.422, - "end": 141.433, - "bars": 4 - } - ], - "role_candidates": { - "kick": { - "total_available": 16, - "top_candidates": [ - { - "rank": 1, - "file_name": "BBH - Primer Impacto - Kick 5.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Kick 5.wav", - "score": 0.658173, - "cosine": 0.677478, - "segment_score": 0.807539, - "catalog_score": 0.540981, - "tempo": 117.454, - "key": "Gm", - "duration": 0.5 - }, - { - "rank": 2, - "file_name": "BBH - Primer Impacto - Kick 1.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Kick 1.wav", - "score": 0.650067, - "cosine": 0.633787, - "segment_score": 0.771427, - "catalog_score": 0.540981, - "tempo": 117.454, - "key": "Am", - "duration": 0.5 - }, - { - "rank": 3, - "file_name": "BBH - Primer Impacto - Kick 8.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Kick 8.wav", - "score": 0.642297, - "cosine": 0.689128, - "segment_score": 0.809562, - "catalog_score": 0.5, - "tempo": 258.398, - "key": "Fm", - "duration": 0.484 - } - ] - }, - "snare": { - "total_available": 28, - "top_candidates": [ - { - "rank": 1, - "file_name": "MT Clap & Snare Hit 05.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Clap & Snare Hit 05.wav", - "score": 0.642515, - "cosine": 0.742869, - "segment_score": 0.87862, - "catalog_score": 0.529168, - "tempo": 258.398, - "key": "Dm", - "duration": 0.72 - }, - { - "rank": 2, - "file_name": "MT Clap & Snare Hit 15.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Clap & Snare Hit 15.wav", - "score": 0.623005, - "cosine": 0.754711, - "segment_score": 0.800798, - "catalog_score": 0.518602, - "tempo": 234.908, - "key": "Dm", - "duration": 0.642 - }, - { - "rank": 3, - "file_name": "BBH - Primer Impacto - Clap 1.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Clap 1.wav", - "score": 0.621014, - "cosine": 0.780775, - "segment_score": 0.805699, - "catalog_score": 0.528549, - "tempo": 117.454, - "key": "A#m", - "duration": 0.545 - } - ] - }, - "hat": { - "total_available": 32, - "top_candidates": [ - { - "rank": 1, - "file_name": "BBH - Primer Impacto - Open Hat 2.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 2.wav", - "score": 0.602448, - "cosine": 0.750913, - "segment_score": 0.789455, - "catalog_score": 0.539635, - "tempo": 258.398, - "key": "Cm", - "duration": 0.625 - }, - { - "rank": 2, - "file_name": "BBH - Primer Impacto - Open Hat 9.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Open Hat 9.wav", - "score": 0.592739, - "cosine": 0.764186, - "segment_score": 0.682635, - "catalog_score": 0.5, - "tempo": 258.398, - "key": "Gm", - "duration": 0.38 - }, - { - "rank": 3, - "file_name": "MT Hat Hit 04.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Hat Hit 04.wav", - "score": 0.55811, - "cosine": 0.747485, - "segment_score": 0.747228, - "catalog_score": 0.5, - "tempo": 135.999, - "key": "G", - "duration": 0.233 - } - ] - }, - "bass_loop": { - "total_available": 37, - "top_candidates": [ - { - "rank": 1, - "file_name": "Bass_Loop_03_G#m_125.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Bass_Loop_03_G#m_125.wav", - "score": 0.877488, - "cosine": 0.803278, - "segment_score": 0.883592, - "catalog_score": 0.617711, - "tempo": 123.047, - "key": "Cm", - "duration": 7.68 - }, - { - "rank": 2, - "file_name": "BBH - Primer Impacto - Bass Loop 06 Dmin.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\BBH - Primer Impacto - Bass Loop 06 Dmin.wav", - "score": 0.82587, - "cosine": 0.698374, - "segment_score": 0.799662, - "catalog_score": 0.890835, - "tempo": 123.047, - "key": "Dm", - "duration": 3.84 - }, - { - "rank": 3, - "file_name": "Bass_Loop_05_Cm_125.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Bass_Loop_05_Cm_125.wav", - "score": 0.818811, - "cosine": 0.695605, - "segment_score": 0.883218, - "catalog_score": 0.617711, - "tempo": 63.024, - "key": "C", - "duration": 7.68 - } - ] - }, - "vocal_loop": { - "total_available": 24, - "top_candidates": [ - { - "rank": 1, - "file_name": "MT Vocal Loop 12 125.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Vocal Loop 12 125.wav", - "score": 0.932334, - "cosine": 0.827361, - "segment_score": 0.923902, - "catalog_score": 0.999437, - "tempo": 123.047, - "key": "D#", - "duration": 1.92 - }, - { - "rank": 2, - "file_name": "MT Vocal Loop 11 125.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Vocal Loop 11 125.wav", - "score": 0.921701, - "cosine": 0.832834, - "segment_score": 0.920162, - "catalog_score": 0.948909, - "tempo": 123.047, - "key": "D#m", - "duration": 1.92 - }, - { - "rank": 3, - "file_name": "MT Vocal Loop 02 128.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\MT Vocal Loop 02 128.wav", - "score": 0.862394, - "cosine": 0.845787, - "segment_score": 0.954025, - "catalog_score": 0.882953, - "tempo": 123.047, - "key": "G#m", - "duration": 3.75 - } - ] - }, - "top_loop": { - "total_available": 144, - "top_candidates": [ - { - "rank": 1, - "file_name": "Top_Loop_11_Any_125.wav", - "path": "C:\\ProgramData\\Ableton\\Live 12 Suite\\Resources\\MIDI Remote Scripts\\librerias\\all_tracks\\Top_Loop_11_Any_125.wav", - "score": 0.906089, - "cosine": 0.752537, - "segment_score": 0.768995, - "catalog_score": 0.859437, - "tempo": 123.047, - "key": "Cm", - "duration": 7.68 - }, - { - "rank": 2, - "file_name": "drum_loop_21_am_125.wav", - "path": "c:\\programdata\\ableton\\live 12 suite\\resources\\midi remote scripts\\librerias\\all_tracks\\drum_loop_21_am_125.wav", - "score": 0.893566, - "cosine": 0.813975, - "segment_score": 0.954219, - "catalog_score": 0.799711, - "tempo": 123.047, - "key": "A#m", - "duration": 7.68 - }, - { - "rank": 3, - "file_name": "drum_loop_23_am_125.wav", - "path": "c:\\programdata\\ableton\\live 12 suite\\resources\\midi remote scripts\\librerias\\all_tracks\\drum_loop_23_am_125.wav", - "score": 0.887869, - "cosine": 0.822104, - "segment_score": 0.94301, - "catalog_score": 0.799711, - "tempo": 123.047, - "key": "A#m", - "duration": 7.68 - } - ] - } - } - } - ], - "contamination_analysis": { - "cross_role_files": [], - "potential_mismatches": [], - "role_score_stats": { - "kick": { - "min": 0.6423, - "max": 0.6582, - "avg": 0.6502, - "count": 3 - }, - "snare": { - "min": 0.621, - "max": 0.6425, - "avg": 0.6288, - "count": 3 - }, - "hat": { - "min": 0.5581, - "max": 0.6024, - "avg": 0.5844, - "count": 3 - }, - "bass_loop": { - "min": 0.8188, - "max": 0.8775, - "avg": 0.8407, - "count": 3 - }, - "vocal_loop": { - "min": 0.8624, - "max": 0.9323, - "avg": 0.9055, - "count": 3 - }, - "top_loop": { - "min": 0.8879, - "max": 0.9061, - "avg": 0.8958, - "count": 3 - } - } - } -} \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_codex_loop.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_codex_loop.ps1 deleted file mode 100644 index c3c0610..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_codex_loop.ps1 +++ /dev/null @@ -1,157 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$TaskFile, - - [Parameter(Mandatory = $true)] - [string]$ReportFile, - - [string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path, - [string]$GlmModel = "glm-5", - [string]$GlmBaseUrl = $(if ($env:ANTHROPIC_BASE_URL) { $env:ANTHROPIC_BASE_URL } else { "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" }), - [string]$GlmAuthToken = $env:ANTHROPIC_AUTH_TOKEN, - [string]$GlmAgentsFile = "", - [string]$CodexModel = "", - [string]$TelegramBotToken = $env:TELEGRAM_BOT_TOKEN, - [string]$TelegramChatId = $env:TELEGRAM_CHAT_ID, - [string]$TelegramConfigPath = (Join-Path $PSScriptRoot "telegram.local.json"), - [switch]$SkipCodexReview -) - -$ErrorActionPreference = "Stop" - -function Require-Command([string]$Name) { - if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { - throw "Command not found: $Name" - } -} - -function Resolve-RepoPath([string]$BasePath, [string]$TargetPath) { - if ([System.IO.Path]::IsPathRooted($TargetPath)) { - return [System.IO.Path]::GetFullPath($TargetPath) - } - return [System.IO.Path]::GetFullPath((Join-Path $BasePath $TargetPath)) -} - -function Resolve-TelegramSettings() { - if (([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) -and (Test-Path -LiteralPath $TelegramConfigPath)) { - $config = Get-Content -LiteralPath $TelegramConfigPath -Raw | ConvertFrom-Json - if ([string]::IsNullOrWhiteSpace($TelegramBotToken)) { - $script:TelegramBotToken = $config.bot_token - } - if ([string]::IsNullOrWhiteSpace($TelegramChatId)) { - $script:TelegramChatId = $config.chat_id - } - } -} - -function Send-LoopNotification([string]$Message) { - Resolve-TelegramSettings - if ([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) { - return - } - - $notifier = Join-Path $PSScriptRoot "send_telegram_notification.ps1" - try { - & $notifier -Message $Message -BotToken $TelegramBotToken -ChatId $TelegramChatId -ConfigPath $TelegramConfigPath - } - catch { - Write-Warning ("Telegram notification failed: " + $_.Exception.Message) - } -} - -function Resolve-CodexCommand() { - $cmd = Get-Command "codex.cmd" -ErrorAction SilentlyContinue - if ($cmd) { - return $cmd.Source - } - - $fallback = Get-Command "codex" -ErrorAction SilentlyContinue - if ($fallback) { - return $fallback.Source - } - - throw "Command not found: codex" -} - -$projectPath = (Resolve-Path -LiteralPath $ProjectRoot).Path -$taskPath = (Resolve-Path -LiteralPath $TaskFile).Path -$reportPath = Resolve-RepoPath $projectPath $ReportFile -$codexCommand = Resolve-CodexCommand - -$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" -$runDir = Join-Path $projectPath ("automation\\runs\\loop_" + $timestamp) -New-Item -ItemType Directory -Force -Path $runDir | Out-Null -$codexStdoutPath = Join-Path $runDir "codex_stdout.txt" -$codexMessagePath = Join-Path $runDir "codex_last_message.txt" - -$glmRunner = Join-Path $PSScriptRoot "run_glm_cycle.ps1" -Send-LoopNotification("GLM/Codex loop started: $(Split-Path -Leaf $taskPath)") -& $glmRunner ` - -TaskFile $taskPath ` - -ReportFile $reportPath ` - -ProjectRoot $projectPath ` - -Model $GlmModel ` - -BaseUrl $GlmBaseUrl ` - -AuthToken $GlmAuthToken ` - -AgentsFile $GlmAgentsFile ` - -TelegramBotToken $TelegramBotToken ` - -TelegramChatId $TelegramChatId ` - -TelegramConfigPath $TelegramConfigPath - -if ($SkipCodexReview) { - Send-LoopNotification("GLM/Codex loop finished without Codex review: $(Split-Path -Leaf $taskPath)") - Write-Host "GLM worker finished. Codex review skipped by flag." - return -} - -$reviewPrompt = @" -Read this worker task file: -$taskPath - -Read this GLM report: -$reportPath - -Your job: -1. Inspect the real diff in the repository. -2. Verify whether GLM actually implemented what the report claims. -3. Fix anything incorrect, incomplete, or unsafe. -4. Run the relevant validations mentioned by the task/report. -5. Leave the repository in the best corrected state you can reach in one pass. -6. Write a concise final summary to the output file configured by the CLI. - -Be strict about overclaims. The code is the source of truth, not the report. -"@ - -$codexArgs = @( - "exec", - "--dangerously-bypass-approvals-and-sandbox", - "-C", $projectPath, - "-o", $codexMessagePath -) - -if (-not [string]::IsNullOrWhiteSpace($CodexModel)) { - $codexArgs += @("-m", $CodexModel) -} - -$codexArgs += $reviewPrompt - -Write-Host "" -Write-Host "Running Codex review/correction pass..." -Send-LoopNotification("Codex review started: $(Split-Path -Leaf $taskPath)") - -try { - & $codexCommand @codexArgs 2>&1 | Tee-Object -FilePath $codexStdoutPath -} -catch { - Send-LoopNotification("Codex review failed: $(Split-Path -Leaf $taskPath)`n$($_.Exception.Message)") - throw -} - -Send-LoopNotification("GLM/Codex loop finished: $(Split-Path -Leaf $taskPath)`nReport: $(Split-Path -Leaf $reportPath)`nCodex note: $(Split-Path -Leaf $codexMessagePath)") - -Write-Host "" -Write-Host "Loop finished." -Write-Host "Task: $taskPath" -Write-Host "GLM report: $reportPath" -Write-Host "Codex note: $codexMessagePath" -Write-Host "Codex stdout:$codexStdoutPath" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_cycle.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_cycle.ps1 deleted file mode 100644 index 928f644..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/run_glm_cycle.ps1 +++ /dev/null @@ -1,162 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$TaskFile, - - [Parameter(Mandatory = $true)] - [string]$ReportFile, - - [string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path, - [string]$Model = "glm-5", - [string]$BaseUrl = $(if ($env:ANTHROPIC_BASE_URL) { $env:ANTHROPIC_BASE_URL } else { "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" }), - [string]$AuthToken = $env:ANTHROPIC_AUTH_TOKEN, - [string]$AgentsFile = (Join-Path $PSScriptRoot "glm_agents.team.json"), - [string]$TelegramBotToken = $env:TELEGRAM_BOT_TOKEN, - [string]$TelegramChatId = $env:TELEGRAM_CHAT_ID, - [string]$TelegramConfigPath = (Join-Path $PSScriptRoot "telegram.local.json"), - [switch]$VerboseLogs -) - -$ErrorActionPreference = "Stop" - -function Require-Command([string]$Name) { - if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { - throw "Command not found: $Name" - } -} - -function Require-File([string]$PathValue, [string]$Label) { - if (-not (Test-Path -LiteralPath $PathValue)) { - throw "$Label not found: $PathValue" - } -} - -function Resolve-RepoPath([string]$BasePath, [string]$TargetPath) { - if ([System.IO.Path]::IsPathRooted($TargetPath)) { - return [System.IO.Path]::GetFullPath($TargetPath) - } - return [System.IO.Path]::GetFullPath((Join-Path $BasePath $TargetPath)) -} - -function Resolve-TelegramSettings() { - if (([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) -and (Test-Path -LiteralPath $TelegramConfigPath)) { - $config = Get-Content -LiteralPath $TelegramConfigPath -Raw | ConvertFrom-Json - if ([string]::IsNullOrWhiteSpace($TelegramBotToken)) { - $script:TelegramBotToken = $config.bot_token - } - if ([string]::IsNullOrWhiteSpace($TelegramChatId)) { - $script:TelegramChatId = $config.chat_id - } - } -} - -function Send-RunNotification([string]$Message) { - Resolve-TelegramSettings - if ([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) { - return - } - - $notifier = Join-Path $PSScriptRoot "send_telegram_notification.ps1" - try { - & $notifier -Message $Message -BotToken $TelegramBotToken -ChatId $TelegramChatId -ConfigPath $TelegramConfigPath - } - catch { - Write-Warning ("Telegram notification failed: " + $_.Exception.Message) - } -} - -Require-Command "claude" -Require-File $TaskFile "Task file" - -if ([string]::IsNullOrWhiteSpace($BaseUrl)) { - throw "ANTHROPIC_BASE_URL is not set. Pass -BaseUrl or export the env var first." -} -if ([string]::IsNullOrWhiteSpace($AuthToken)) { - throw "ANTHROPIC_AUTH_TOKEN is not set. Pass -AuthToken or export the env var first." -} - -$env:ANTHROPIC_BASE_URL = $BaseUrl -$env:ANTHROPIC_AUTH_TOKEN = $AuthToken -$env:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1" -$env:ANTHROPIC_MODEL = $Model -$env:ANTHROPIC_SMALL_FAST_MODEL = $Model -$env:ANTHROPIC_DEFAULT_HAIKU_MODEL = $Model -$env:ANTHROPIC_DEFAULT_SONNET_MODEL = $Model -$env:ANTHROPIC_DEFAULT_OPUS_MODEL = $Model - -$taskPath = (Resolve-Path -LiteralPath $TaskFile).Path -$projectPath = (Resolve-Path -LiteralPath $ProjectRoot).Path -$reportPath = Resolve-RepoPath $projectPath $ReportFile -$reportDir = Split-Path -Parent $reportPath -New-Item -ItemType Directory -Force -Path $reportDir | Out-Null - -$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" -$runDir = Join-Path $projectPath ("automation\\runs\\glm_" + $timestamp) -New-Item -ItemType Directory -Force -Path $runDir | Out-Null -$stdoutPath = Join-Path $runDir "glm_stdout.txt" - -$prompt = @" -You are running as the GLM worker on this Windows repository. - -Repository root: -$projectPath - -Task file to follow exactly: -$taskPath - -You must: -1. Read the task markdown and implement the requested changes in the repository. -2. Run the validations requested by the task. -3. Create or overwrite this report file with a truthful report: -$reportPath -4. Do not overclaim. If something is incomplete, say so explicitly in the report. -5. Keep the diff focused. -6. If custom agents are available, use them aggressively and in parallel where safe: - - planner first - - implementer_core and implementer_aux for disjoint work - - validator before finishing - - retrieval_reviewer or runtime_guard when relevant - - reporter last - -Open and follow the task markdown from disk instead of asking for the task again. -"@ - -$claudeArgs = @( - "-p", - "--dangerously-skip-permissions", - "--effort", "max", - "--model", $Model, - "--add-dir", $projectPath -) - -if (-not [string]::IsNullOrWhiteSpace($AgentsFile)) { - $agentsPath = (Resolve-Path -LiteralPath $AgentsFile).Path - $claudeArgs += @("--agents", (Get-Content -LiteralPath $agentsPath -Raw)) -} - -if ($VerboseLogs) { - $claudeArgs += "--verbose" -} - -Write-Host "Running GLM worker with model $Model..." -Send-RunNotification("GLM worker started: $(Split-Path -Leaf $taskPath)") - -try { - $prompt | & claude @claudeArgs 2>&1 | Tee-Object -FilePath $stdoutPath -} -catch { - Send-RunNotification("GLM worker failed: $(Split-Path -Leaf $taskPath)`n$($_.Exception.Message)") - throw -} - -if (-not (Test-Path -LiteralPath $reportPath)) { - Send-RunNotification("GLM worker failed: missing report for $(Split-Path -Leaf $taskPath)") - throw "GLM finished but did not create the expected report file: $reportPath" -} - -Send-RunNotification("GLM worker finished: $(Split-Path -Leaf $taskPath)`nReport: $(Split-Path -Leaf $reportPath)") - -Write-Host "" -Write-Host "GLM cycle finished." -Write-Host "Task: $taskPath" -Write-Host "Report: $reportPath" -Write-Host "Stdout: $stdoutPath" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/run_task_queue.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/run_task_queue.ps1 deleted file mode 100644 index 0f52379..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/run_task_queue.ps1 +++ /dev/null @@ -1,141 +0,0 @@ -param( - [string]$QueueFile = (Join-Path $PSScriptRoot "task_queue.json"), - [string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path, - [string]$GlmModel = "glm-5", - [string]$GlmBaseUrl = $(if ($env:ANTHROPIC_BASE_URL) { $env:ANTHROPIC_BASE_URL } else { "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" }), - [string]$GlmAuthToken = $env:ANTHROPIC_AUTH_TOKEN, - [string]$GlmAgentsFile = (Join-Path $PSScriptRoot "glm_agents.team.json"), - [string]$CodexModel = "", - [string]$TelegramBotToken = $env:TELEGRAM_BOT_TOKEN, - [string]$TelegramChatId = $env:TELEGRAM_CHAT_ID, - [string]$TelegramConfigPath = (Join-Path $PSScriptRoot "telegram.local.json"), - [int]$PollSeconds = 30, - [switch]$Watch, - [switch]$ContinueOnError -) - -$ErrorActionPreference = "Stop" - -function Resolve-RepoPath([string]$BasePath, [string]$TargetPath) { - if ([System.IO.Path]::IsPathRooted($TargetPath)) { - return [System.IO.Path]::GetFullPath($TargetPath) - } - return [System.IO.Path]::GetFullPath((Join-Path $BasePath $TargetPath)) -} - -function Load-Queue([string]$PathValue) { - return Get-Content -LiteralPath $PathValue -Raw | ConvertFrom-Json -Depth 20 -} - -function Save-Queue([string]$PathValue, $QueueObject) { - $QueueObject | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $PathValue -Encoding UTF8 -} - -function Resolve-TelegramSettings() { - if (([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) -and (Test-Path -LiteralPath $TelegramConfigPath)) { - $config = Get-Content -LiteralPath $TelegramConfigPath -Raw | ConvertFrom-Json - if ([string]::IsNullOrWhiteSpace($TelegramBotToken)) { - $script:TelegramBotToken = $config.bot_token - } - if ([string]::IsNullOrWhiteSpace($TelegramChatId)) { - $script:TelegramChatId = $config.chat_id - } - } -} - -function Send-QueueNotification([string]$Message) { - Resolve-TelegramSettings - if ([string]::IsNullOrWhiteSpace($TelegramBotToken) -or [string]::IsNullOrWhiteSpace($TelegramChatId)) { - return - } - - $notifier = Join-Path $PSScriptRoot "send_telegram_notification.ps1" - try { - & $notifier -Message $Message -BotToken $TelegramBotToken -ChatId $TelegramChatId -ConfigPath $TelegramConfigPath - } - catch { - Write-Warning ("Telegram notification failed: " + $_.Exception.Message) - } -} - -function Find-NextTask($QueueObject) { - foreach ($task in $QueueObject.tasks) { - if ($task.enabled -and $task.status -eq "pending") { - return $task - } - } - return $null -} - -$projectPath = (Resolve-Path -LiteralPath $ProjectRoot).Path -$queuePath = Resolve-RepoPath $projectPath $QueueFile -$loopRunner = Join-Path $PSScriptRoot "run_glm_codex_loop.ps1" -$historyDir = Join-Path $projectPath "automation\\runs\\queue" -New-Item -ItemType Directory -Force -Path $historyDir | Out-Null - -Send-QueueNotification("AbletonMCP_AI queue runner started on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'). Watching=$Watch ContinueOnError=$ContinueOnError") - -do { - $queue = Load-Queue $queuePath - $task = Find-NextTask $queue - - if ($null -eq $task) { - if ($Watch) { - Start-Sleep -Seconds $PollSeconds - continue - } - break - } - - $taskPath = Resolve-RepoPath $projectPath $task.task_file - $reportPath = Resolve-RepoPath $projectPath $task.report_file - - $task.status = "running" - $task.started_at = (Get-Date).ToString("s") - Save-Queue $queuePath $queue - Send-QueueNotification("Queue task started: [$($task.id)] $($task.title)") - - try { - & $loopRunner ` - -TaskFile $taskPath ` - -ReportFile $reportPath ` - -ProjectRoot $projectPath ` - -GlmModel $GlmModel ` - -GlmBaseUrl $GlmBaseUrl ` - -GlmAuthToken $GlmAuthToken ` - -GlmAgentsFile $GlmAgentsFile ` - -CodexModel $CodexModel ` - -TelegramBotToken $TelegramBotToken ` - -TelegramChatId $TelegramChatId ` - -TelegramConfigPath $TelegramConfigPath - - $queue = Load-Queue $queuePath - foreach ($item in $queue.tasks) { - if ($item.id -eq $task.id) { - $item.status = "completed" - $item.completed_at = (Get-Date).ToString("s") - break - } - } - Save-Queue $queuePath $queue - Send-QueueNotification("Queue task completed: [$($task.id)] $($task.title)") - } - catch { - $queue = Load-Queue $queuePath - foreach ($item in $queue.tasks) { - if ($item.id -eq $task.id) { - $item.status = "failed" - $item.failed_at = (Get-Date).ToString("s") - $item.error = $_.Exception.Message - break - } - } - Save-Queue $queuePath $queue - Send-QueueNotification("Queue task failed: [$($task.id)] $($task.title)`n$($_.Exception.Message)") - - if (-not $ContinueOnError) { - throw - } - } -} -while ($true) diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/send_telegram_notification.ps1 b/AbletonMCP_AI_BAK_20260328_200801/automation/send_telegram_notification.ps1 deleted file mode 100644 index 162458b..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/send_telegram_notification.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$Message, - - [string]$BotToken = $env:TELEGRAM_BOT_TOKEN, - [string]$ChatId = $env:TELEGRAM_CHAT_ID, - [string]$ConfigPath = (Join-Path $PSScriptRoot "telegram.local.json") -) - -$ErrorActionPreference = "Stop" - -if (([string]::IsNullOrWhiteSpace($BotToken) -or [string]::IsNullOrWhiteSpace($ChatId)) -and (Test-Path -LiteralPath $ConfigPath)) { - $config = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json - if ([string]::IsNullOrWhiteSpace($BotToken)) { - $BotToken = $config.bot_token - } - if ([string]::IsNullOrWhiteSpace($ChatId)) { - $ChatId = $config.chat_id - } -} - -if ([string]::IsNullOrWhiteSpace($BotToken) -or [string]::IsNullOrWhiteSpace($ChatId)) { - exit 0 -} - -$uri = "https://api.telegram.org/bot$BotToken/sendMessage" -$body = @{ - chat_id = $ChatId - text = $Message - disable_web_page_preview = $true -} - -Invoke-RestMethod -Uri $uri -Method Post -Body $body | Out-Null diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/ableton-glm-loop.service b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/ableton-glm-loop.service deleted file mode 100644 index 9629ddc..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/ableton-glm-loop.service +++ /dev/null @@ -1,18 +0,0 @@ -[Unit] -Description=AbletonMCP_AI autonomous GLM/Codex queue -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=ren -WorkingDirectory=/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI -Environment=LOCAL_ENV_FILE=/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/wsl.local.env -ExecStart=/bin/bash /mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/wsl/run_task_queue.sh -Restart=always -RestartSec=15 -StandardOutput=append:/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/wsl_runtime/logs/service.log -StandardError=append:/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/wsl_runtime/logs/service.log - -[Install] -WantedBy=multi-user.target diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/bootstrap_wsl_runtime.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/bootstrap_wsl_runtime.sh deleted file mode 100644 index 30d5b9c..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/bootstrap_wsl_runtime.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -RUNTIME_DIR="$PROJECT_ROOT/automation/wsl_runtime" -CODEX_HOME_DIR="$RUNTIME_DIR/codex_home" -WINDOWS_CODEX_HOME="/mnt/c/Users/ren/.codex" -ENV_FILE="$PROJECT_ROOT/automation/wsl.local.env" -OPENAI_API_KEY_VALUE="" - -mkdir -p "$CODEX_HOME_DIR" "$RUNTIME_DIR/logs" - -if [[ -f "$WINDOWS_CODEX_HOME/auth.json" && ! -f "$CODEX_HOME_DIR/auth.json" ]]; then - cp "$WINDOWS_CODEX_HOME/auth.json" "$CODEX_HOME_DIR/auth.json" -fi - -if [[ -f "$CODEX_HOME_DIR/auth.json" ]]; then - OPENAI_API_KEY_VALUE="$(jq -r '.OPENAI_API_KEY // empty' "$CODEX_HOME_DIR/auth.json" 2>/dev/null || true)" -fi - -cat > "$CODEX_HOME_DIR/config.toml" <<'EOF' -model = "gpt-5.4" - -[sandbox_workspace_write] -network_access = true -EOF - -cat > "$ENV_FILE" <> "$ENV_FILE" -fi - -chmod 600 "$ENV_FILE" "$CODEX_HOME_DIR/auth.json" 2>/dev/null || true -chmod +x "$SCRIPT_DIR/"*.sh - -echo "WSL runtime bootstrapped" -echo "Runtime dir: $RUNTIME_DIR" -echo "Env file: $ENV_FILE" -echo "Codex home: $CODEX_HOME_DIR" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/docker-compose.yml b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/docker-compose.yml deleted file mode 100644 index 79eca51..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/docker-compose.yml +++ /dev/null @@ -1,163 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - container_name: abletonmcp-postgres - restart: unless-stopped - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} - POSTGRES_DB: ${POSTGRES_BOOTSTRAP_DB:-postgres} - PGDATA: /var/lib/postgresql/data/pgdata - GITEA_DB_NAME: ${GITEA_DB_NAME:-gitea} - N8N_DB_NAME: ${N8N_DB_NAME:-n8n} - volumes: - - postgres-data:/var/lib/postgresql/data - - ./initdb:/docker-entrypoint-initdb.d:ro - ports: - - "${POSTGRES_PORT:-5432}:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_BOOTSTRAP_DB:-postgres}"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 20s - networks: - - internal - - redis: - image: redis:7-alpine - container_name: abletonmcp-redis - restart: unless-stopped - command: - - redis-server - - --requirepass - - ${REDIS_PASSWORD:-changeme} - - --appendonly - - "yes" - - --save - - "60" - - "1000" - volumes: - - redis-data:/data - ports: - - "${REDIS_PORT:-6379}:6379" - healthcheck: - test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD:-changeme} ping | grep -q PONG"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - networks: - - internal - - gitea: - image: gitea/gitea:1.21-rootless - container_name: abletonmcp-gitea - restart: unless-stopped - environment: - USER_UID: 1000 - USER_GID: 1000 - GITEA__database__DB_TYPE: postgres - GITEA__database__HOST: postgres:5432 - GITEA__database__NAME: ${GITEA_DB_NAME:-gitea} - GITEA__database__USER: ${POSTGRES_USER:-postgres} - GITEA__database__PASSWD: ${POSTGRES_PASSWORD:-changeme} - GITEA__server__DOMAIN: ${GITEA_DOMAIN:-localhost} - GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:-http://localhost:3000} - GITEA__server__HTTP_PORT: 3000 - GITEA__server__SSH_DOMAIN: ${GITEA_SSH_DOMAIN:-localhost} - GITEA__server__SSH_PORT: ${GITEA_SSH_PORT:-222} - GITEA__server__START_SSH_SERVER: "true" - GITEA__server__SSH_LISTEN_PORT: 222 - GITEA__security__INSTALL_LOCK: ${GITEA_SECURITY_INSTALL_LOCK:-true} - GITEA__service__DISABLE_REGISTRATION: "true" - GITEA__server__OFFLINE_MODE: ${GITEA_OFFLINE_MODE:-true} - volumes: - - gitea-data:/var/lib/gitea - - gitea-config:/etc/gitea - - gitea-logs:/var/log/gitea - ports: - - "${GITEA_HTTP_PORT:-3000}:3000" - - "${GITEA_SSH_PORT:-222}:222" - healthcheck: - test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/healthz || exit 1"] - interval: 15s - timeout: 5s - retries: 10 - start_period: 45s - depends_on: - postgres: - condition: service_healthy - networks: - - internal - - n8n: - image: n8nio/n8n:latest - container_name: abletonmcp-n8n - restart: unless-stopped - environment: - DB_TYPE: postgresdb - DB_POSTGRESDB_HOST: postgres - DB_POSTGRESDB_PORT: 5432 - DB_POSTGRESDB_DATABASE: ${N8N_DB_NAME:-n8n} - DB_POSTGRESDB_USER: ${POSTGRES_USER:-postgres} - DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD:-changeme} - N8N_PORT: 5678 - N8N_PROTOCOL: http - N8N_HOST: ${N8N_HOST:-localhost} - N8N_PATH: ${N8N_PATH:-/} - N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:-changeme-change-this} - N8N_LOG_LEVEL: ${N8N_LOG_LEVEL:-info} - N8N_EXECUTIONS_MODE: ${N8N_EXECUTIONS_MODE:-regular} - N8N_BASIC_AUTH_ACTIVE: ${N8N_BASIC_AUTH_ACTIVE:-true} - N8N_BASIC_AUTH_USER: ${N8N_BASIC_AUTH_USER:-admin} - N8N_BASIC_AUTH_PASSWORD: ${N8N_BASIC_AUTH_PASSWORD:-changeme} - N8N_COOKIE_POLICY: ${N8N_COOKIE_POLICY:-lax} - N8N_HOST_ALLOW_LIST: ${N8N_HOST_ALLOW_LIST:-localhost,127.0.0.1} - N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://localhost:5678/} - N8N_EDITOR_BASE_URL: ${N8N_EDITOR_BASE_URL:-http://localhost:5678} - GENERIC_TIMEZONE: ${TZ:-UTC} - TZ: ${TZ:-UTC} - N8N_DIAGNOSTICS_ENABLED: ${N8N_DIAGNOSTICS_ENABLED:-false} - N8N_VERSION_NOTIFICATIONS_ENABLED: ${N8N_VERSION_NOTIFICATIONS_ENABLED:-false} - volumes: - - n8n-data:/home/node/.n8n - - n8n-logs:/home/node/.npm/_logs - - ${PROJECT_PATH:-/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI}:/project:rw - - ${PROJECT_PATH:-/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI}/automation/workflows:/workflows:ro - ports: - - "${N8N_PORT:-5678}:5678" - healthcheck: - test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"] - interval: 15s - timeout: 5s - retries: 10 - start_period: 45s - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - networks: - - internal - -networks: - internal: - name: abletonmcp-network - driver: bridge - -volumes: - postgres-data: - name: abletonmcp-postgres-data - gitea-data: - name: abletonmcp-gitea-data - gitea-config: - name: abletonmcp-gitea-config - gitea-logs: - name: abletonmcp-gitea-logs - redis-data: - name: abletonmcp-redis-data - n8n-data: - name: abletonmcp-n8n-data - n8n-logs: - name: abletonmcp-n8n-logs diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/initdb/01-init-multiple-dbs.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/initdb/01-init-multiple-dbs.sh deleted file mode 100644 index 67f014b..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/initdb/01-init-multiple-dbs.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -set -eu - -create_db() { - db_name="$1" - psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "${POSTGRES_BOOTSTRAP_DB:-postgres}" <<-EOSQL - SELECT 'CREATE DATABASE "${db_name}"' - WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${db_name}')\gexec -EOSQL -} - -if [ -n "${GITEA_DB_NAME:-}" ]; then - create_db "$GITEA_DB_NAME" -fi - -if [ -n "${N8N_DB_NAME:-}" ]; then - create_db "$N8N_DB_NAME" -fi diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/install_service.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/install_service.sh deleted file mode 100644 index 2630724..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/install_service.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SERVICE_SRC="$SCRIPT_DIR/ableton-glm-loop.service" -SERVICE_DST="/etc/systemd/system/ableton-glm-loop.service" - -sudo cp "$SERVICE_SRC" "$SERVICE_DST" -sudo systemctl daemon-reload -sudo systemctl enable ableton-glm-loop.service -sudo systemctl restart ableton-glm-loop.service -sudo systemctl status --no-pager ableton-glm-loop.service || true diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_codex_loop.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_codex_loop.sh deleted file mode 100644 index 05e7c2f..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_codex_loop.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -LOCAL_ENV_FILE="${LOCAL_ENV_FILE:-$PROJECT_ROOT/automation/wsl.local.env}" - -if [[ -f "$LOCAL_ENV_FILE" ]]; then - # shellcheck disable=SC1090 - source "$LOCAL_ENV_FILE" -fi - -TASK_FILE="${1:?task file is required}" -REPORT_FILE="${2:?report file is required}" -GLM_MODEL="${GLM_MODEL:-glm-5}" -CODEX_MODEL="${CODEX_MODEL:-gpt-5.4}" -SKIP_CODEX_REVIEW="${SKIP_CODEX_REVIEW:-0}" -CODEX_HOME="${CODEX_HOME:-$PROJECT_ROOT/automation/wsl_runtime/codex_home}" -export CODEX_HOME -if [[ -n "${OPENAI_API_KEY:-}" ]]; then - export OPENAI_API_KEY -fi - -RUN_DIR="$PROJECT_ROOT/automation/runs/loop_$(date +%Y%m%d_%H%M%S)" -CODEX_STDOUT_PATH="$RUN_DIR/codex_stdout.txt" -CODEX_MESSAGE_PATH="$RUN_DIR/codex_last_message.txt" -mkdir -p "$RUN_DIR" - -notify() { - "$SCRIPT_DIR/send_telegram.sh" "$1" || true -} - -notify "GLM/Codex loop started: $(basename "$TASK_FILE")" -"$SCRIPT_DIR/run_glm_cycle.sh" "$TASK_FILE" "$REPORT_FILE" - -if [[ "$SKIP_CODEX_REVIEW" == "1" ]]; then - notify "GLM/Codex loop finished without Codex review: $(basename "$TASK_FILE")" - exit 0 -fi - -notify "Codex review started: $(basename "$TASK_FILE")" - -WIN_TASK_FILE="$(wslpath -w "$TASK_FILE")" -WIN_REPORT_FILE="$(wslpath -w "$REPORT_FILE")" -WIN_PROJECT_ROOT="$(wslpath -w "$PROJECT_ROOT")" -WIN_CODEX_MESSAGE_PATH="$(wslpath -w "$CODEX_MESSAGE_PATH")" -WIN_REVIEW_SCRIPT="$(wslpath -w "$PROJECT_ROOT/automation/invoke_codex_review.ps1")" - -if ! /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$WIN_REVIEW_SCRIPT" -TaskFile "$WIN_TASK_FILE" -ReportFile "$WIN_REPORT_FILE" -ProjectRoot "$WIN_PROJECT_ROOT" -OutputFile "$WIN_CODEX_MESSAGE_PATH" -CodexModel "$CODEX_MODEL" 2>&1 | tee "$CODEX_STDOUT_PATH"; then - notify "Codex review failed: $(basename "$TASK_FILE")" - exit 1 -fi - -notify "GLM/Codex loop finished: $(basename "$TASK_FILE")" -echo "Loop finished" -echo "Task: $TASK_FILE" -echo "GLM report: $REPORT_FILE" -echo "Codex note: $CODEX_MESSAGE_PATH" -echo "Codex stdout:$CODEX_STDOUT_PATH" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_cycle.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_cycle.sh deleted file mode 100644 index d5020a4..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_glm_cycle.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -LOCAL_ENV_FILE="${LOCAL_ENV_FILE:-$PROJECT_ROOT/automation/wsl.local.env}" - -if [[ -f "$LOCAL_ENV_FILE" ]]; then - # shellcheck disable=SC1090 - source "$LOCAL_ENV_FILE" -fi - -TASK_FILE="${1:?task file is required}" -REPORT_FILE="${2:?report file is required}" -GLM_MODEL="${GLM_MODEL:-glm-5}" -GLM_AGENTS_FILE="${GLM_AGENTS_FILE:-$PROJECT_ROOT/automation/glm_agents.team.json}" - -export ANTHROPIC_BASE_URL="${ANTHROPIC_BASE_URL:-https://coding-intl.dashscope.aliyuncs.com/apps/anthropic}" -export ANTHROPIC_AUTH_TOKEN="${ANTHROPIC_AUTH_TOKEN:?ANTHROPIC_AUTH_TOKEN is required}" -export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1" -export ANTHROPIC_MODEL="$GLM_MODEL" -export ANTHROPIC_SMALL_FAST_MODEL="$GLM_MODEL" -export ANTHROPIC_DEFAULT_HAIKU_MODEL="$GLM_MODEL" -export ANTHROPIC_DEFAULT_SONNET_MODEL="$GLM_MODEL" -export ANTHROPIC_DEFAULT_OPUS_MODEL="$GLM_MODEL" - -RUN_DIR="$PROJECT_ROOT/automation/runs/glm_$(date +%Y%m%d_%H%M%S)" -STDOUT_PATH="$RUN_DIR/glm_stdout.txt" -mkdir -p "$RUN_DIR" "$(dirname "$REPORT_FILE")" - -notify() { - "$SCRIPT_DIR/send_telegram.sh" "$1" || true -} - -PROMPT=$(cat <&1 | tee "$STDOUT_PATH"; then - notify "GLM worker failed: $(basename "$TASK_FILE")" - exit 1 -fi - -if [[ ! -f "$REPORT_FILE" ]]; then - notify "GLM worker failed: missing report for $(basename "$TASK_FILE")" - echo "missing report: $REPORT_FILE" >&2 - exit 1 -fi - -notify "GLM worker finished: $(basename "$TASK_FILE")" -echo "GLM cycle finished" -echo "Task: $TASK_FILE" -echo "Report: $REPORT_FILE" -echo "Stdout: $STDOUT_PATH" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_task_queue.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_task_queue.sh deleted file mode 100644 index aec3377..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/run_task_queue.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -QUEUE_FILE="${QUEUE_FILE:-$PROJECT_ROOT/automation/task_queue.json}" -LOCAL_ENV_FILE="${LOCAL_ENV_FILE:-$PROJECT_ROOT/automation/wsl.local.env}" -POLL_SECONDS="${POLL_SECONDS:-30}" -WATCH="${WATCH:-1}" -CONTINUE_ON_ERROR="${CONTINUE_ON_ERROR:-1}" - -if [[ -f "$LOCAL_ENV_FILE" ]]; then - # shellcheck disable=SC1090 - source "$LOCAL_ENV_FILE" -fi - -notify() { - "$SCRIPT_DIR/send_telegram.sh" "$1" || true -} - -queue_has_pending() { - jq -e '.tasks[] | select(.enabled == true and .status == "pending")' "$QUEUE_FILE" >/dev/null -} - -read_next_task() { - jq -r '.tasks[] | select(.enabled == true and .status == "pending") | @base64' "$QUEUE_FILE" | head -n 1 -} - -update_task_status() { - local task_id="$1" - local status="$2" - local field="$3" - local value="$4" - local tmp - local queue_dir - queue_dir="$(dirname "$QUEUE_FILE")" - tmp="$(mktemp "$queue_dir/.task_queue.tmp.XXXXXX")" - jq --arg id "$task_id" --arg status "$status" --arg field "$field" --arg value "$value" ' - .tasks |= map( - if .id == $id then - .status = $status | .[$field] = $value - else - . - end - )' "$QUEUE_FILE" > "$tmp" - mv "$tmp" "$QUEUE_FILE" -} - -set_task_error() { - local task_id="$1" - local message="$2" - local tmp - local queue_dir - queue_dir="$(dirname "$QUEUE_FILE")" - tmp="$(mktemp "$queue_dir/.task_queue.tmp.XXXXXX")" - jq --arg id "$task_id" --arg msg "$message" ' - .tasks |= map( - if .id == $id then - .error = $msg - else - . - end - )' "$QUEUE_FILE" > "$tmp" - mv "$tmp" "$QUEUE_FILE" -} - -notify "AbletonMCP_AI queue runner started on $(date '+%Y-%m-%d %H:%M:%S')" - -while true; do - if ! queue_has_pending; then - if [[ "$WATCH" == "1" ]]; then - sleep "$POLL_SECONDS" - continue - fi - break - fi - - task_b64="$(read_next_task)" - if [[ -z "$task_b64" ]]; then - sleep "$POLL_SECONDS" - continue - fi - - task_json="$(printf '%s' "$task_b64" | base64 -d)" - task_id="$(printf '%s' "$task_json" | jq -r '.id')" - task_title="$(printf '%s' "$task_json" | jq -r '.title')" - task_file_rel="$(printf '%s' "$task_json" | jq -r '.task_file')" - report_file_rel="$(printf '%s' "$task_json" | jq -r '.report_file')" - task_file="$PROJECT_ROOT/${task_file_rel//\\//}" - report_file="$PROJECT_ROOT/${report_file_rel//\\//}" - - update_task_status "$task_id" "running" "started_at" "$(date -Iseconds)" - notify "Queue task started: [$task_id] $task_title" - - if "$SCRIPT_DIR/run_glm_codex_loop.sh" "$task_file" "$report_file"; then - update_task_status "$task_id" "completed" "completed_at" "$(date -Iseconds)" - notify "Queue task completed: [$task_id] $task_title" - else - update_task_status "$task_id" "failed" "failed_at" "$(date -Iseconds)" - set_task_error "$task_id" "task runner failed" - notify "Queue task failed: [$task_id] $task_title" - if [[ "$CONTINUE_ON_ERROR" != "1" ]]; then - exit 1 - fi - fi -done diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install.sh deleted file mode 100644 index b7b84bb..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install.sh +++ /dev/null @@ -1,281 +0,0 @@ -#!/usr/bin/env bash -# -# install.sh - Install Docker, Docker Compose, and local Python runtime on Ubuntu 24.04 WSL2 -# Idempotent: safe to run multiple times -# - -set -euo pipefail - -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly NC='\033[0m' - -log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } -log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -log_error() { echo -e "${RED}[ERROR]${NC} $*"; } - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" -PROJECT_ROOT="$(cd "$AUTOMATION_DIR/.." && pwd)" -RUNTIME_DIR="$AUTOMATION_DIR/wsl_runtime" -VENV_DIR="$RUNTIME_DIR/venv" - -check_sudo() { - if [[ $EUID -eq 0 ]]; then - log_error "This script should not be run as root. It will use sudo when needed." - exit 1 - fi -} - -detect_ubuntu() { - if [[ ! -f /etc/os-release ]]; then - log_error "Cannot detect OS version. /etc/os-release not found." - exit 1 - fi - - # shellcheck disable=SC1091 - source /etc/os-release - if [[ "${ID:-}" != "ubuntu" ]]; then - log_warn "This script is designed for Ubuntu. Detected: ${ID:-unknown}" - fi - - log_info "Detected Ubuntu ${VERSION_ID:-unknown}" -} - -check_wsl2() { - if [[ ! -f /proc/version ]]; then - log_warn "Cannot verify WSL environment" - return - fi - - if grep -qi microsoft /proc/version; then - log_info "Running in WSL environment" - else - log_warn "Not running in WSL. This script is designed for WSL2." - fi -} - -install_docker() { - log_info "Checking Docker installation..." - - if command -v docker >/dev/null 2>&1; then - log_info "Docker already installed: $(docker --version)" - else - log_info "Installing Docker..." - sudo apt-get update -q - sudo apt-get install -y \ - ca-certificates \ - curl \ - gnupg \ - lsb-release \ - software-properties-common - - sudo install -m 0755 -d /etc/apt/keyrings - if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg - sudo chmod a+r /etc/apt/keyrings/docker.gpg - fi - - local codename - codename=$(. /etc/os-release && echo "$VERSION_CODENAME") - sudo tee /etc/apt/sources.list.d/docker.list >/dev/null </dev/null 2>&1; then - log_info "Python already installed: $(python3 --version)" - else - sudo apt-get update -q - sudo apt-get install -y python3 python3-pip python3-venv python3-full - fi -} - -install_utilities() { - log_info "Installing system utilities..." - - sudo apt-get update -q - sudo apt-get install -y \ - jq \ - git \ - curl \ - wget \ - rsync \ - net-tools \ - dnsutils \ - htop \ - ncdu \ - tree \ - unzip \ - zip \ - httpie \ - python3-rich \ - pipx -} - -configure_docker_wsl2() { - log_info "Configuring Docker for WSL..." - - local docker_config_dir="/etc/docker" - local docker_config_file="$docker_config_dir/daemon.json" - - if [[ ! -f "$docker_config_file" ]]; then - sudo mkdir -p "$docker_config_dir" - sudo tee "$docker_config_file" >/dev/null <<'EOF' -{ - "log-driver": "json-file", - "log-opts": { - "max-size": "10m", - "max-file": "3" - }, - "features": { - "containerd-snapshotter": true - }, - "iptables": false -} -EOF - sudo systemctl restart docker - fi - - local bashrc_file="$HOME/.bashrc" - if ! grep -q 'WSL Docker helpers' "$bashrc_file" 2>/dev/null; then - cat >> "$bashrc_file" <<'EOF' - -# WSL Docker helpers -export DOCKER_HOST=unix:///var/run/docker.sock -EOF - fi -} - -handle_windows_paths() { - log_info "Ensuring project symlink exists..." - if [[ ! -L "$HOME/ableton-mcp-ai" ]]; then - ln -sfn "$PROJECT_ROOT" "$HOME/ableton-mcp-ai" - fi -} - -install_python_dependencies() { - log_info "Preparing local virtual environment..." - mkdir -p "$RUNTIME_DIR" - - if [[ ! -d "$VENV_DIR" ]]; then - python3 -m venv "$VENV_DIR" - fi - - # shellcheck disable=SC1091 - source "$VENV_DIR/bin/activate" - python -m pip install --upgrade pip - - local found_req=false - local requirements_files=( - "$PROJECT_ROOT/MCP_Server/requirements.txt" - "$PROJECT_ROOT/requirements.txt" - ) - - for req_file in "${requirements_files[@]}"; do - if [[ -f "$req_file" ]]; then - log_info "Installing dependencies from: $req_file" - python -m pip install -r "$req_file" - found_req=true - fi - done - - if [[ "$found_req" == "false" ]]; then - log_warn "No requirements.txt files found" - fi - - deactivate -} - -verify_installation() { - log_info "Verifying installation..." - - local all_good=true - - if command -v docker >/dev/null 2>&1; then - log_info "OK Docker: $(docker --version)" - else - log_error "FAIL Docker not found" - all_good=false - fi - - if docker compose version >/dev/null 2>&1; then - log_info "OK Docker Compose: $(docker compose version)" - else - log_error "FAIL Docker Compose not found" - all_good=false - fi - - if command -v python3 >/dev/null 2>&1; then - log_info "OK Python: $(python3 --version)" - else - log_error "FAIL Python3 not found" - all_good=false - fi - - if [[ -x "$VENV_DIR/bin/python" ]]; then - log_info "OK Venv: $VENV_DIR" - else - log_error "FAIL Venv not found at $VENV_DIR" - all_good=false - fi - - if command -v jq >/dev/null 2>&1; then - log_info "OK jq installed" - else - log_error "FAIL jq not found" - all_good=false - fi - - if [[ "$all_good" == "true" ]]; then - log_info "All dependencies installed successfully" - return 0 - fi - - log_error "Some dependencies failed to install" - return 1 -} - -main() { - log_info "Starting AbletonMCP-AI WSL installation..." - echo - - check_sudo - detect_ubuntu - check_wsl2 - echo - - install_docker - install_python - install_utilities - configure_docker_wsl2 - handle_windows_paths - install_python_dependencies - echo - - verify_installation - echo - - log_info "Installation complete" - log_info "Next step: run ./setup.sh and then ./start.sh" -} - -main "$@" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install_systemd.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install_systemd.sh deleted file mode 100644 index c6c12d5..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/install_systemd.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -SYSTEMD_DIR="$WSL_DIR/systemd" - -if [[ $EUID -ne 0 ]]; then - echo "Run with sudo" - exit 1 -fi - -for service_file in "$SYSTEMD_DIR"/*.service; do - cp "$service_file" /etc/systemd/system/"$(basename "$service_file")" -done - -systemctl daemon-reload -systemctl enable abletonmcp-stack.service abletonmcp-queue-runner.service -echo "Installed systemd units" -echo "Enabled by default: abletonmcp-stack.service, abletonmcp-queue-runner.service" -echo "Optional unit left disabled: abletonmcp-glm-runner.service" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/logs.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/logs.sh deleted file mode 100644 index 10e3c30..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/logs.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" -DOCKER_ENV_FILE="$WSL_DIR/.env" -COMPOSE_FILE="$WSL_DIR/docker-compose.yml" -LOGS_DIR="$AUTOMATION_DIR/wsl_runtime/logs" - -follow="${1:-all}" - -compose_cmd() { - docker compose --env-file "$DOCKER_ENV_FILE" -f "$COMPOSE_FILE" "$@" -} - -case "$follow" in - docker) - compose_cmd logs -f - ;; - queue) - tail -f "$LOGS_DIR/queue-runner.log" - ;; - all) - compose_cmd logs -f & - docker_pid=$! - if [[ -f "$LOGS_DIR/queue-runner.log" ]]; then - tail -f "$LOGS_DIR/queue-runner.log" & - tail_pid=$! - wait "$docker_pid" "$tail_pid" - else - wait "$docker_pid" - fi - ;; - *) - echo "Usage: $0 [all|docker|queue]" - exit 1 - ;; -esac diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/restart.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/restart.sh deleted file mode 100644 index 08c9870..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/restart.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -"$SCRIPT_DIR/stop.sh" -sleep 2 -"$SCRIPT_DIR/start.sh" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/setup.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/setup.sh deleted file mode 100644 index 5b14825..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/setup.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly BLUE='\033[0;34m' -readonly NC='\033[0m' - -log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } -log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -log_step() { echo -e "${BLUE}[STEP]${NC} $*"; } - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" -PROJECT_ROOT="$(cd "$AUTOMATION_DIR/.." && pwd)" -RUNTIME_DIR="$AUTOMATION_DIR/wsl_runtime" -DOCKER_ENV_FILE="$WSL_DIR/.env" -RUNNER_ENV_FILE="$AUTOMATION_DIR/wsl.local.env" -PROJECT_LINK="$HOME/ableton-mcp-ai" - -generate_secret() { - openssl rand -hex "${1:-16}" 2>/dev/null || python3 - <<'PY' -import secrets -print(secrets.token_hex(16)) -PY -} - -ensure_dirs() { - log_step "Creating runtime directories" - mkdir -p \ - "$RUNTIME_DIR/logs" \ - "$RUNTIME_DIR/pids" \ - "$RUNTIME_DIR/data" \ - "$AUTOMATION_DIR/reports" \ - "$AUTOMATION_DIR/runs" \ - "$AUTOMATION_DIR/tasks" \ - "$AUTOMATION_DIR/workflows" \ - "$WSL_DIR/initdb" -} - -ensure_symlink() { - if [[ ! -L "$PROJECT_LINK" ]]; then - ln -sfn "$PROJECT_ROOT" "$PROJECT_LINK" - fi - log_info "Project link: $PROJECT_LINK" -} - -write_docker_env() { - if [[ -f "$DOCKER_ENV_FILE" ]]; then - log_info "Docker env already exists: $DOCKER_ENV_FILE" - return - fi - - log_step "Generating docker env" - cat > "$DOCKER_ENV_FILE" < "$RUNNER_ENV_FILE" </dev/null || { log_error "Docker is not installed"; exit 1; } - docker compose version >/dev/null || { log_error "Docker Compose plugin is not available"; exit 1; } - docker info >/dev/null || { log_error "Docker daemon is not running"; exit 1; } - [[ -f "$DOCKER_ENV_FILE" ]] || { log_error "Missing docker env: $DOCKER_ENV_FILE"; exit 1; } - [[ -f "$COMPOSE_FILE" ]] || { log_error "Missing compose file: $COMPOSE_FILE"; exit 1; } -} - -wait_for_postgres() { - log_info "Waiting for PostgreSQL" - for _ in $(seq 1 60); do - if compose_cmd exec -T postgres pg_isready -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_BOOTSTRAP_DB:-postgres}" >/dev/null 2>&1; then - return 0 - fi - sleep 2 - done - log_error "PostgreSQL did not become ready in time" - exit 1 -} - -wait_for_service_http() { - local service="$1" - local url="$2" - log_info "Waiting for $service" - for _ in $(seq 1 60); do - if curl -fsS "$url" >/dev/null 2>&1; then - return 0 - fi - sleep 2 - done - log_warn "$service is not healthy yet: $url" - return 1 -} - -ensure_database() { - local db_name="$1" - if compose_cmd exec -T postgres psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_BOOTSTRAP_DB:-postgres}" -tAc "SELECT 1 FROM pg_database WHERE datname='${db_name}'" | grep -q 1; then - return 0 - fi - compose_cmd exec -T postgres psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_BOOTSTRAP_DB:-postgres}" -c "CREATE DATABASE \"${db_name}\"" -} - -ensure_gitea_admin() { - local user="${GITEA_ADMIN_USER:-giteaadmin}" - local password="${GITEA_ADMIN_PASSWORD:-changeme}" - local email="${GITEA_ADMIN_EMAIL:-admin@localhost}" - if compose_cmd exec -T gitea sh -c "HOME=/tmp /usr/local/bin/gitea admin user list 2>/dev/null | awk 'NR > 1 && \$2 == \"${user}\" { found=1 } END { exit found ? 0 : 1 }'"; then - return 0 - fi - compose_cmd exec -T gitea sh -c "HOME=/tmp /usr/local/bin/gitea admin user create --admin --username '${user}' --password '${password}' --email '${email}' --must-change-password=false" >/dev/null 2>&1 || log_warn "Could not auto-create Gitea admin user; complete first-run in UI if needed" -} - -start_docker_stack() { - log_step "Starting Docker services" - compose_cmd up -d postgres redis - wait_for_postgres - ensure_database "${GITEA_DB_NAME:-gitea}" - ensure_database "${N8N_DB_NAME:-n8n}" - compose_cmd up -d gitea n8n - wait_for_service_http "Gitea" "http://localhost:${GITEA_HTTP_PORT:-3000}/api/healthz" || true - wait_for_service_http "n8n" "http://localhost:${N8N_PORT:-5678}/healthz" || true - ensure_gitea_admin -} - -start_queue_runner() { - if [[ "$START_QUEUE_RUNNER" != "1" ]]; then - log_info "Queue runner startup skipped by START_QUEUE_RUNNER=$START_QUEUE_RUNNER" - return - fi - - if command -v systemctl >/dev/null 2>&1 && systemctl is-active abletonmcp-queue-runner.service >/dev/null 2>&1; then - log_info "Queue runner already managed by systemd" - return - fi - - local pid_file="$PID_DIR/queue-runner.pid" - if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then - log_info "Queue runner already running" - return - fi - - log_step "Starting autonomous queue runner" - nohup bash "$WSL_DIR/run_task_queue.sh" > "$LOGS_DIR/queue-runner.log" 2>&1 & - echo $! > "$pid_file" - log_info "Queue runner PID: $(cat "$pid_file")" -} - -main() { - check_prerequisites - start_docker_stack - start_queue_runner - echo - log_info "Stack started" - echo " Gitea: http://localhost:${GITEA_HTTP_PORT:-3000}" - echo " n8n: http://localhost:${N8N_PORT:-5678}" -} - -main "$@" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/status.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/status.sh deleted file mode 100644 index 691ea89..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/status.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly BLUE='\033[0;34m' -readonly RED='\033[0;31m' -readonly NC='\033[0m' - -ok() { echo -e "${GREEN}OK${NC} $*"; } -warn() { echo -e "${YELLOW}WARN${NC} $*"; } -fail() { echo -e "${RED}FAIL${NC} $*"; } -step() { echo -e "${BLUE}$*${NC}"; } - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" -DOCKER_ENV_FILE="$WSL_DIR/.env" -COMPOSE_FILE="$WSL_DIR/docker-compose.yml" -PID_DIR="$AUTOMATION_DIR/wsl_runtime/pids" -LOGS_DIR="$AUTOMATION_DIR/wsl_runtime/logs" - -compose_cmd() { - docker compose --env-file "$DOCKER_ENV_FILE" -f "$COMPOSE_FILE" "$@" -} - -step "Docker" -if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then - ok "docker daemon running" -else - fail "docker daemon unavailable" -fi -echo - -step "Compose services" -if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then - compose_cmd ps || true -else - warn "compose file or docker missing" -fi -echo - -step "Queue runner" -if [[ -f "$PID_DIR/queue-runner.pid" ]] && kill -0 "$(cat "$PID_DIR/queue-runner.pid")" 2>/dev/null; then - ok "queue runner PID $(cat "$PID_DIR/queue-runner.pid")" -elif command -v systemctl >/dev/null 2>&1 && systemctl is-active abletonmcp-queue-runner.service >/dev/null 2>&1; then - ok "queue runner managed by systemd" -else - warn "queue runner not running" -fi -echo - -step "Logs" -if [[ -d "$LOGS_DIR" ]]; then - ls -1 "$LOGS_DIR" | sed 's/^/ - /' -else - warn "no logs directory" -fi diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/stop.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/stop.sh deleted file mode 100644 index c85919a..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/scripts/stop.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly BLUE='\033[0;34m' -readonly NC='\033[0m' - -log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } -log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -log_step() { echo -e "${BLUE}[STEP]${NC} $*"; } - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WSL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -AUTOMATION_DIR="$(cd "$WSL_DIR/.." && pwd)" -DOCKER_ENV_FILE="$WSL_DIR/.env" -COMPOSE_FILE="$WSL_DIR/docker-compose.yml" -PID_DIR="$AUTOMATION_DIR/wsl_runtime/pids" - -compose_cmd() { - docker compose --env-file "$DOCKER_ENV_FILE" -f "$COMPOSE_FILE" "$@" -} - -stop_runner() { - local pid_file="$1" - if [[ ! -f "$pid_file" ]]; then - return - fi - local pid - pid="$(cat "$pid_file")" - if kill -0 "$pid" 2>/dev/null; then - kill -TERM "$pid" 2>/dev/null || true - sleep 2 - kill -KILL "$pid" 2>/dev/null || true - fi - rm -f "$pid_file" -} - -main() { - log_step "Stopping queue runner" - stop_runner "$PID_DIR/queue-runner.pid" - echo - log_step "Stopping Docker services" - if command -v docker >/dev/null 2>&1; then - compose_cmd down "$@" || true - else - log_warn "Docker not installed" - fi - log_info "Stack stopped" -} - -main "$@" diff --git a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/send_telegram.sh b/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/send_telegram.sh deleted file mode 100644 index 7f55670..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/automation/wsl/send_telegram.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -LOCAL_ENV_FILE="${LOCAL_ENV_FILE:-$PROJECT_ROOT/automation/wsl.local.env}" - -if [[ -f "$LOCAL_ENV_FILE" ]]; then - # shellcheck disable=SC1090 - source "$LOCAL_ENV_FILE" -fi - -MESSAGE="${1:-}" -if [[ -z "$MESSAGE" ]]; then - exit 0 -fi - -BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}" -CHAT_ID="${TELEGRAM_CHAT_ID:-}" - -if [[ -z "$BOT_TOKEN" || -z "$CHAT_ID" ]]; then - exit 0 -fi - -curl -fsS -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ - --data-urlencode "chat_id=${CHAT_ID}" \ - --data-urlencode "text=${MESSAGE}" \ - --data "disable_web_page_preview=true" >/dev/null diff --git a/AbletonMCP_AI_BAK_20260328_200801/load_samples.py b/AbletonMCP_AI_BAK_20260328_200801/load_samples.py deleted file mode 100644 index 6e58efc..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/load_samples.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python -""" -Script para cargar samples en Ableton MCP AI -Este script guía al usuario para cargar samples manualmente o usa el browser -""" -import os - -# Configuración de samples -SAMPLES_CONFIG = { - "kick": r"C:\Users\ren\embeddings\all_tracks\BBH - Primer Impacto - Kick 1.wav", - "clap": r"C:\Users\ren\embeddings\all_tracks\MT Clap & Snare Hit 05.wav", - "hat": r"C:\Users\ren\embeddings\all_tracks\BBH - Primer Impacto - Closed Hat 3.wav", - "bass": r"C:\Users\ren\embeddings\all_tracks\MT_Bass Loop 04 F 125.wav", -} - -def generate_instrument_setup_guide(): - """Genera instrucciones detalladas para cargar samples""" - - guide = """ -╔══════════════════════════════════════════════════════════════════╗ -║ CONFIGURACIÓN DE INSTRUMENTOS - HOUSE 90s ║ -╚══════════════════════════════════════════════════════════════════╝ - -Para que suene tu track, necesitas cargar instrumentos en cada track MIDI. - -🥁 TRACK 0 - KICK (Rojo): - 1. Arrastra "Drum Rack" del browser al track - 2. Arrastra tu sample de kick al pad C1 (nota 36) - 3. Ajusta volumen a -3dB - -👏 TRACK 1 - CLAP (Naranja): - 1. Mismo Drum Rack o uno nuevo - 2. Arrastra sample de clap/snare al pad D2 (nota 50) - 3. Volumen a -6dB - -🎩 TRACK 2 - HIHAT (Amarillo): - 1. Drum Rack - 2. Sample de closed hat al pad F#1 (nota 42) - 3. Volumen a -12dB - -🎸 TRACK 3 - BASS (Azul): - Opción A (Sampler): - 1. Arrastra "Simpler" al track - 2. Arrastra loop de bass (MT_Bass Loop 04 F 125.wav) - 3. Ajusta para que C3 dispare el sample - - Opción B (Synth): - 1. Carga "Operator" - 2. Preset "Sub Bass" o "Funky Bass" - 3. Ajusta envolvente: Attack 5ms, Decay 200ms, Sustain 80% - -🎹 TRACK 4 - CHORDS (Purpura): - 1. Carga "Wavetable" o "Analog" - 2. Preset "House Chords", "Chord Stab" o "Vintage Keys" - 3. Añade reverb (Return A) al 20% - -═══════════════════════════════════════════════════════════════════ - -📁 SAMPLES RECOMENDADOS DE TU LIBRERÍA: - -Kick: BBH - Primer Impacto - Kick 1.wav -Clap: MT Clap & Snare Hit 05.wav -Hat: BBH - Primer Impacto - Closed Hat 3.wav -Bass: MT_Bass Loop 04 F 125.wav - -═══════════════════════════════════════════════════════════════════ - -⚡ ATAJO RÁPIDO: -Si tienes Drum Rack presets guardados: -1. Busca en el browser: "Drums > Drum Rack" -2. Arrastra a cada track de drums -3. Los clips MIDI ya están programados y sonarán automáticamente - -═══════════════════════════════════════════════════════════════════ -""" - return guide - - -def verify_samples(): - """Verifica qué samples existen""" - samples_dir = r"C:\Users\ren\embeddings\all_tracks" - - print("\n📂 Verificando samples en librería...") - print(f"Directorio: {samples_dir}") - print("-" * 50) - - if not os.path.exists(samples_dir): - print("❌ Directorio no encontrado!") - return False - - # Buscar archivos comunes - found = [] - for f in os.listdir(samples_dir)[:20]: # Primeros 20 - if f.endswith('.wav'): - found.append(f) - - print(f"✓ {len(found)} archivos WAV encontrados") - print("\nEjemplos:") - for f in found[:10]: - print(f" - {f}") - - return True - - -if __name__ == "__main__": - print(generate_instrument_setup_guide()) - verify_samples() diff --git a/AbletonMCP_AI_BAK_20260328_200801/roadmap.md b/AbletonMCP_AI_BAK_20260328_200801/roadmap.md deleted file mode 100644 index 0d06b52..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/roadmap.md +++ /dev/null @@ -1,215 +0,0 @@ -# AbletonMCP-AI — Tech House Professional DJ Roadmap - -> Repositorio: AbletonMCP_AI | Foco: **Tech House** (122–128 BPM) -> Última actualización: 2026-03-28 - ---- - -## Estado actual del sistema - -El sistema tiene una base sólida: -- `song_generator.py` (~6k líneas): blueprints de secciones, perfiles de arrangement, bus system, gain calibration, device automation por sección -- `vector_manager.py`: semantic search + filtros de duración (evita canciones completas) -- `role_matcher.py`: validación de samples por rol con thresholds y penalizaciones -- `sample_selector.py`: selección de samples con compatibilidad BPM/key -- `audio_resampler.py`: resample layers y análisis -- `server.py` (~7k líneas): herramientas MCP expuestas al AI - ---- - -## 🔴 BUG FIXES — Prioridad crítica - -### BF-01: Track "AUDIO ATMOS 2" con canción completa -- **Problema**: El filtro de duración (max 45s) no se aplica consistentemente en todos los paths de carga -- **Causa**: `_build_audio_fallback_sample_paths` en `server.py` tiene glob patterns que ignoran el filtro de `vector_manager` -- **Fix**: Reindexar la librería con `reindex_library` y verificar que `_find_library_file` rechaza archivos donde `soundfile` lanza excepción (duración = -1) -- **Archivo**: `server.py` → `_find_library_file`, `_build_audio_fallback_sample_paths` - -### BF-02: Embeddings desactualizados post-cambios -- **Problema**: Al agregar nuevos samples a la librería, el índice `.sample_embeddings.json` no se reconstruye automáticamente -- **Fix**: Agregar un hash de fingerprint del directorio (mtime o conteo) al índice y validar en cada arranque -- **Archivo**: `vector_manager.py` → `_load_or_build_index` - -### BF-03: Colisión de nombres de track "AUDIO X 2" -- **Problema**: Cuando se crea un segundo track con el mismo nombre, Ableton le agrega "2" automáticamente y luego el sistema no lo encuentra por nombre -- **Fix**: Normalizar búsquedas de tracks usando índice numérico en vez de nombre como identificador primario -- **Archivo**: `server.py` → `_mute_tracks_for_audio_layers`, `_normalize_track_name` - -### BF-04: Linting errors restantes -- **Problema**: Múltiples errores de ruff reportados en `ruff_errors.txt` (principalmente F821 undefined names, E501 line length) -- **Fix**: Pasar `ruff check --fix` y revisar los F821 manualmente -- **Archivos**: `server.py`, `song_generator.py` - -### BF-05: `soundfile` excepción silenciosa permite archivos inválidos -- **Problema**: Si `soundfile` falla al leer un archivo, se asigna `duration = -1` pero el archivo igual puede ser insertado en escenarios de fallback -- **Fix**: En `_find_library_file`, `duration < 0` debe ser rechazado explícitamente también en el branch de fallback de `glob` -- **Archivo**: `server.py` → `_find_library_file` - ---- - -## 🟠 MEJORAS CORE — Tech House específico - -### MJ-01: Blueprints de sección optimizados para Tech House DJ -- **Qué**: Los blueprints actuales (`standard`, `extended`, `club`) son genéricos. Tech House DJ requiere intros/outros de 16-32 bars para beatmatching -- **Cambio**: - ```python - 'tech-house-dj': [ - ('INTRO DJ', 32, 8, 'intro', 1), # 32 bars solo kick+bass para mezcla - ('GROOVE A', 16, 16, 'build', 2), - ('VOX TEASE', 8, 20, 'build', 3), - ('DROP A', 32, 30, 'drop', 5), - ('BREAK', 8, 22, 'break', 1), - ('BUILD', 8, 24, 'build', 3), - ('DROP B', 32, 32, 'drop', 5), - ('OUTRO DJ', 32, 8, 'outro', 1), # 32 bars solo kick+bass para salida - ] - ``` -- **Archivo**: `song_generator.py` → `SECTION_BLUEPRINTS` - -### MJ-02: Patrones rítmicos tech house propios -- **Qué**: Los patrones de kick/hat/perc están en `create_drum_pattern` (server.py) como presets genéricos. Tech House usa swing, offbeat hats, y kicks con ghost notes -- **Cambio**: Agregar presets `'tech-house-swing'`, `'tech-house-jackin'`, `'tech-house-minimal'` con: - - Kick en 1 y 3 con variaciones en 2.5 y 3.5 - - Hi-hat con swing 16% y offbeats en 1/8 - - Clap/snare en 2 y 4 con ghost notes -- **Archivo**: `server.py` → `create_drum_pattern` - -### MJ-03: Bass lines tech house -- **Qué**: `create_bassline` genera 4 estilos genéricos. Tech House requiere basslines sincopadas y groovy -- **Cambio**: Agregar estilo `'tech-house'` con notas en posiciones off-beat, slides, y variaciones de velocidad para groove -- **Archivo**: `server.py` → `create_bassline` - -### MJ-04: Chord progressions tech house -- **Qué**: `CHORD_PROGRESSIONS` en `song_generator.py` no tiene entradas específicas para tech house -- **Cambio**: Agregar progressiones: - - Am → Fm → Gm (oscura, hipnótica) - - Dm → Am → Dm (loop de dos acordes para drop) - - Cm → Gm (minimalista con tensión) -- **Archivo**: `song_generator.py` → `CHORD_PROGRESSIONS` - -### MJ-05: Estilo Latin Tech House -- **Qué**: El sistema tiene menciones de `latin-industrial` (Eli Brown) pero no tiene patrones de percusión latina implementados -- **Cambio**: Agregar preset `'latin-tech-house'` con: - - Conga / bongo patterns como perc layer - - Bass con notas sincopadas al estilo afro-percusivo - - Vocal shots ("ey", "come on") en offbeats -- **Archivo**: `song_generator.py`, `server.py` - -### MJ-06: Genre keyword expansion en VectorManager -- **Qué**: Las búsquedas semánticas usan strings genéricos. Tech house tiene vocabulario específico -- **Cambio**: Agregar diccionario de términos preferidos por género que enriquecen el query: - ```python - GENRE_SEARCH_TERMS = { - 'tech-house': ['groovy', 'driving', 'punchy', 'jackin', 'swinging', 'hypnotic'], - 'house': ['deep', 'soulful', 'warm', 'classic'], - ... - } - ``` -- **Archivo**: `vector_manager.py` o `server.py` - -### MJ-07: Reindex automático al detectar cambios en librería -- **Qué**: El índice de embeddings solo se reconstruye manualmente. Si el usuario agrega samples, no se detectan -- **Cambio**: Al iniciar `VectorManager`, comparar el conteo de archivos actual vs el del índice. Si difieren, rebuild automático -- **Archivo**: `vector_manager.py` → `_load_or_build_index` - ---- - -## 🟡 MEJORAS DJ PRO — Funcionalidades de DJ profesional - -### DJ-01: Track Stems export / bus routing visible -- **Qué**: Un DJ profesional necesita poder exportar stems (kick, bass, music, fx) separados -- **Cambio**: Agregar herramienta `export_stems_config()` que configura los buses para exportación de stems individual, nombrando y coloreando cada bus consistentemente -- **Archivo**: `server.py` (nuevo tool) - -### DJ-02: Harmonic mixing — Camelot wheel -- **Qué**: El sistema elige keys pero no verifica compatibilidad con Camelot wheel para mezcla armónica -- **Cambio**: Agregar función `get_compatible_keys(current_key)` que devuelve keys compatibles en la rueda de Camelot (±1 tono, relativo mayor/menor). Usar en `suggest_key_change` -- **Archivo**: `server.py` → `suggest_key_change` - -### DJ-03: BPM grid automático — Sync markers -- **Qué**: Al generar una canción con intro DJ de 32 bars, colocar marcadores de Ableton (`locators`) en los puntos exactos de cada sección para que el DJ pueda saltar entre puntos -- **Cambio**: Usar el comando `create_arrangement_locator` de Ableton API para marcar cada sección -- **Archivo**: `server.py`, `Remote_Script.py` (agregar comando de socket) - -### DJ-04: Loop regions automáticas -- **Qué**: Marcar los drops como loop regions en Ableton para que el DJ pueda activar el loop con un botón -- **Cambio**: Al generar la canción, colocar punch-in / punch-out en los drops principales -- **Archivo**: `server.py` - -### DJ-05: Energy curve explícita -- **Qué**: El sistema tiene `ROLE_ACTIVITY` con valores de energía por sección pero no hay una curva visible para el usuario -- **Cambio**: Al terminar la generación, imprimir (en el manifest) la curva de energía sección a sección: `[INTRO: 25%] → [BUILD: 70%] → [DROP: 100%]...` -- **Archivo**: `server.py` → manifest / `get_generation_manifest` - -### DJ-06: Referencia de track real — Eli Brown style -- **Qué**: `REFERENCE_TRACK_PROFILES` tiene "Eli Brown - Me Gusta" definido pero no se puede cargar automáticamente una referencia para análisis A/B -- **Cambio**: Hacer funcional el sistema de referencia: si el usuario pone un archivo en `librerias/reference/`, que sea analizable e influya en BPM, key, y energy curve de la generación -- **Archivo**: `server.py`, `audio_resampler.py`, `reference_listener.py` - ---- - -## 🟢 NICE TO HAVE — Calidad de vida - -### NTH-01: Preview de canción antes de generar -- **Qué**: El sistema genera todo de golpe sin preview. Poder ver primero el "blueprint" (qué tracks, qué samples, qué estructura) antes de ejecutar -- **Cambio**: Agregar `preview_generation(genre, style, key, bpm)` que devuelve el manifest sin crear nada en Ableton -- **Archivo**: `server.py`, `song_generator.py` - -### NTH-02: Regeneración selectiva de secciones -- **Qué**: Si el drop no quedó bien, hay que regenerar todo. Debería poder regenerarse solo el drop -- **Cambio**: Agregar `regenerate_section(section_name)` que borra los clips de esa sección y los regenera -- **Archivo**: `server.py` - -### NTH-03: Historial de generaciones -- **Qué**: Solo se guarda el último manifest. Debería haber un historial de las últimas 5 generaciones -- **Cambio**: Guardar manifests en archivos `.json` con timestamp en `librerias/generations/` -- **Archivo**: `server.py` → `_store_generation_manifest` - -### NTH-04: Color coding consistente por género -- **Qué**: Los colores de tracks son estáticos. Tech House podría tener paleta propia (naranja, azul oscuro) -- **Cambio**: Agregar `GENRE_COLOR_PALETTES` y aplicar al generar tracks -- **Archivo**: `song_generator.py` → `TRACK_COLORS` - -### NTH-05: Sample diversity mejorada -- **Qué**: Si la librería tiene 3 kicks, el sistema puede usar el mismo kick en 2 generaciones seguidas -- **Cambio**: Existe `reset_diversity_memory` pero no hay persistencia entre sesiones. Guardar el historial de samples usados en un JSON local -- **Archivo**: `sample_selector.py` - -### NTH-06: Validación de routing en tiempo real -- **Qué**: `validate_set` existe pero no se llama automáticamente al generar -- **Cambio**: Al terminar `generate_track`, llamar automáticamente a `detect_common_issues` y mostrar resumen con numero de errores/warnings -- **Archivo**: `server.py` → `generate_track` - -### NTH-07: howto.md actualizado para Tech House -- **Qué**: El `howto.md` documenta el sistema genérico. Agregar sección específica de "Cómo generar Tech House profesional" con ejemplos de prompts, flujos de trabajo DJ, y settings recomendados -- **Archivo**: `howto.md` - ---- - -## Orden de ejecución recomendado - -| Prioridad | ID | Nombre | Esfuerzo | -|---|---|---|---| -| 1 | BF-01 | Full song en ATMOS track | 1h | -| 2 | BF-02 | Embeddings auto-rebuild | 2h | -| 3 | BF-03 | Colisión de nombres | 2h | -| 4 | MJ-01 | Blueprints DJ 32-bar intro/outro | 1h | -| 5 | MJ-02 | Drum patterns tech house | 2h | -| 6 | DJ-02 | Camelot wheel | 2h | -| 7 | MJ-03 | Bassline tech house | 1h | -| 8 | MJ-05 | Latin tech house preset | 3h | -| 9 | DJ-03 | BPM locators automáticos | 4h | -| 10 | DJ-06 | Referencia de track real | 4h | -| 11 | NTH-01 | Preview pre-generación | 3h | -| 12 | NTH-06 | Auto-validación post-generación | 1h | -| 13 | BF-04 | Linting cleanup | 2h | - ---- - -## Notas arquitectónicas para el salto a Tech House - -1. **BPM default**: cambiar `default_bpm` de `tech-house` de 125 a **126** (sweet spot del género actual) -2. **Key pool**: priorizar `Am`, `Fm`, `Dm` → más oscuras y groovy que las opciones actuales -3. **Swing**: el swing del 8% actual en hats es insuficiente. Tech House moderno usa 12-16% -4. **Sidechain pump**: el threshold actual de -22dB en bass bus es correcto, pero el release de 0.12s es lento. Bajar a 0.08-0.10s para más pump -5. **Atmos tracks**: el vol de 0.50 en `ROLE_MIX['atmos']` es correcto, pero usar filtros HPF altos (>1kHz) para que no compitan con el sub diff --git a/AbletonMCP_AI_BAK_20260328_200801/setup_returns_master.py b/AbletonMCP_AI_BAK_20260328_200801/setup_returns_master.py deleted file mode 100644 index 6218d10..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/setup_returns_master.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Setup Returns and Master Chain for Ableton Live 12 -Creates return tracks and configures master chain -""" -import socket -import json -import time -import os -from datetime import datetime -from typing import Dict, Any - -LOG_FILE = r"C:\Users\ren\Documents\Ableton\Logs\returns_master.txt" - -def log_message(message): - """Log message to file and console""" - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - log_line = f"[{timestamp}] {message}" - print(log_line) - os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(log_line + "\n") - -class AbletonClient: - def __init__(self, host="127.0.0.1", port=9877, timeout=15.0): - self.host = host - self.port = port - self.timeout = timeout - - def send(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: - """Send command to Ableton runtime and get response""" - payload = json.dumps({ - "type": command_type, - "params": params or {}, - }).encode("utf-8") + b"\n" - - with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock: - sock.sendall(payload) - reader = sock.makefile("r", encoding="utf-8") - try: - line = reader.readline() - finally: - reader.close() - try: - sock.shutdown(socket.SHUT_RDWR) - except OSError: - pass - - if not line: - return {"status": "error", "message": f"No response for command: {command_type}"} - - return json.loads(line) - -def main(): - log_message("=" * 60) - log_message("STARTING RETURNS AND MASTER CHAIN SETUP") - log_message("=" * 60) - - client = AbletonClient() - - try: - # Get current session info - log_message("\n--- Getting session info ---") - session_info = client.send("get_session_info") - log_message(f"Session: tracks={session_info.get('result', {}).get('num_tracks', 'N/A')}, returns={session_info.get('result', {}).get('num_return_tracks', 'N/A')}") - - # ======================================== - # CREATE RETURN TRACKS - # ======================================== - log_message("\n" + "=" * 60) - log_message("CREATING RETURN TRACKS") - log_message("=" * 60) - - # 1. A-REVERB (Large Hall) - log_message("\n--- Creating A-REVERB return track ---") - reverb_response = client.send("setup_return_track", { - "preset": "reverb_large", - "name": "A-REVERB" - }) - log_message(f"A-REVERB: {reverb_response.get('status')} - {json.dumps(reverb_response.get('result', reverb_response.get('message')), indent=2)}") - - if reverb_response.get("status") == "success": - return_index = reverb_response.get("result", {}).get("index", 0) - log_message(f"Setting A-REVERB volume to 0.70...") - vol_response = client.send("set_track_volume", { - "track_index": return_index, - "volume": 0.70, - "track_type": "return" - }) - log_message(f"Volume set: {vol_response.get('status')}") - - # 2. B-DELAY (Ping Pong) - log_message("\n--- Creating B-DELAY return track ---") - delay_response = client.send("setup_return_track", { - "preset": "delay_pingpong", - "name": "B-DELAY" - }) - log_message(f"B-DELAY: {delay_response.get('status')} - {json.dumps(delay_response.get('result', delay_response.get('message')), indent=2)}") - - if delay_response.get("status") == "success": - return_index = delay_response.get("result", {}).get("index", 1) - log_message(f"Setting B-DELAY volume to 0.65...") - vol_response = client.send("set_track_volume", { - "track_index": return_index, - "volume": 0.65, - "track_type": "return" - }) - log_message(f"Volume set: {vol_response.get('status')}") - - # 3. C-COMPRESSOR (Parallel compression for sidechain pumping) - log_message("\n--- Creating C-COMPRESSOR return track ---") - comp_response = client.send("setup_return_track", { - "preset": "parallel_comp", - "name": "C-COMPRESSOR" - }) - log_message(f"C-COMPRESSOR: {comp_response.get('status')} - {json.dumps(comp_response.get('result', comp_response.get('message')), indent=2)}") - - if comp_response.get("status") == "success": - return_index = comp_response.get("result", {}).get("index", 2) - log_message(f"Setting C-COMPRESSOR volume to 0.80...") - vol_response = client.send("set_track_volume", { - "track_index": return_index, - "volume": 0.80, - "track_type": "return" - }) - log_message(f"Volume set: {vol_response.get('status')}") - - # ======================================== - # MASTER CHAIN SETUP - # ======================================== - log_message("\n" + "=" * 60) - log_message("SETTING UP MASTER CHAIN") - log_message("=" * 60) - - # Get current master devices - log_message("\n--- Getting current master devices ---") - master_devices = client.send("get_devices", { - "track_type": "master", - "track_index": 0 - }) - devices_list = master_devices.get("result", []) - if isinstance(devices_list, list): - log_message(f"Current master devices: {[d.get('name', '?') if isinstance(d, dict) else str(d) for d in devices_list]}") - else: - log_message(f"Master devices response: {master_devices}") - - # Setup master chain - log_message("\n--- Loading master chain devices ---") - client.timeout = 30.0 - master_chain_response = client.send("setup_master_chain", { - "devices": ["Utility", "EQ Eight", "Compressor", "Limiter"], - "parameters": { - "Utility": { - "Gain": 0.0 - }, - "EQ Eight": { - "Mode": "Stereo" - }, - "Compressor": { - "Threshold": -18.0, - "Ratio": 2.0, - "Attack": 10.0, - "Release": 80.0, - "Makeup": 2.0 - }, - "Limiter": { - "Ceiling": -0.3, - "Release": 50.0 - } - } - }) - log_message(f"Master chain: {master_chain_response.get('status')} - {json.dumps(master_chain_response.get('result', master_chain_response.get('message')), indent=2)}") - - # Set master volume to 0.85 - log_message("\n--- Setting master volume to 0.85 ---") - master_vol_response = client.send("set_track_volume", { - "track_index": 0, - "volume": 0.85, - "track_type": "master" - }) - log_message(f"Master volume: {master_vol_response.get('status')}") - - # ======================================== - # VERIFICATION - # ======================================== - log_message("\n" + "=" * 60) - log_message("VERIFICATION") - log_message("=" * 60) - - # Get final session info - log_message("\n--- Final session info ---") - final_session = client.send("get_session_info") - result = final_session.get("result", {}) - log_message(f"Tracks: {result.get('num_tracks')}, Returns: {result.get('num_return_tracks')}, Scenes: {result.get('num_scenes')}") - - # Get final master devices - log_message("\n--- Final master devices ---") - final_master = client.send("get_devices", { - "track_type": "master", - "track_index": 0 - }) - devices_list = final_master.get("result", []) - if isinstance(devices_list, list): - for d in devices_list: - if isinstance(d, dict): - log_message(f" - {d.get('name', '?')}") - - # Verify return tracks - log_message("\n--- Return tracks ---") - for i in range(3): - ret_info = client.send("get_track_info", { - "track_index": i, - "track_type": "return" - }) - result = ret_info.get("result", {}) - log_message(f" Return {i}: {result.get('name', '?')} - Volume: {result.get('volume', '?'):.2f}" if isinstance(result.get('volume'), (int, float)) else f" Return {i}: {result.get('name', '?')}") - - log_message("\n" + "=" * 60) - log_message("SETUP COMPLETE") - log_message("=" * 60) - - except Exception as e: - log_message(f"Error: {e}") - import traceback - log_message(traceback.format_exc()) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/AbletonMCP_AI_BAK_20260328_200801/start_server.bat b/AbletonMCP_AI_BAK_20260328_200801/start_server.bat deleted file mode 100644 index 8d8be55..0000000 --- a/AbletonMCP_AI_BAK_20260328_200801/start_server.bat +++ /dev/null @@ -1,27 +0,0 @@ -@echo off -echo ============================================ -echo AbletonMCP-AI Server -echo ============================================ -echo. -echo Iniciando servidor MCP... -echo Conectando a Ableton en localhost:9877 -echo. -echo Asegurate de que: -echo 1. Ableton Live 12 esta abierto -echo 2. El Control Surface 'AbletonMCP_AI' esta seleccionado -echo en Preferencias ^> Link/Tempo/MIDI -echo. -echo Presiona Ctrl+C para detener -echo ============================================ -echo. - -cd /d "%~dp0\MCP_Server" - -python server.py -if errorlevel 1 ( - echo. - echo ERROR: No se pudo iniciar el servidor - echo Verifica que Python esta instalado y en el PATH - echo. - pause -) diff --git a/Axiom_25_Classic/Preset.syx b/Axiom_25_Classic/Preset.syx deleted file mode 100644 index 5a36021..0000000 Binary files a/Axiom_25_Classic/Preset.syx and /dev/null differ diff --git a/Axiom_49_61_Classic/Preset.syx b/Axiom_49_61_Classic/Preset.syx deleted file mode 100644 index 7da2273..0000000 Binary files a/Axiom_49_61_Classic/Preset.syx and /dev/null differ diff --git a/BCF2000/Preset.syx b/BCF2000/Preset.syx deleted file mode 100644 index f57e821..0000000 Binary files a/BCF2000/Preset.syx and /dev/null differ diff --git a/BCR2000/Preset.syx b/BCR2000/Preset.syx deleted file mode 100644 index df3e922..0000000 Binary files a/BCR2000/Preset.syx and /dev/null differ diff --git a/KONTROL49/Preset.syx b/KONTROL49/Preset.syx deleted file mode 100644 index 162dcac..0000000 Binary files a/KONTROL49/Preset.syx and /dev/null differ diff --git a/MPD32/Preset.syx b/MPD32/Preset.syx deleted file mode 100644 index f279ce0..0000000 Binary files a/MPD32/Preset.syx and /dev/null differ diff --git a/MPK25/Preset.syx b/MPK25/Preset.syx deleted file mode 100644 index f024608..0000000 Binary files a/MPK25/Preset.syx and /dev/null differ diff --git a/MPK49/Preset.syx b/MPK49/Preset.syx deleted file mode 100644 index d8d9021..0000000 Binary files a/MPK49/Preset.syx and /dev/null differ diff --git a/MPK61/Preset.syx b/MPK61/Preset.syx deleted file mode 100644 index c34941f..0000000 Binary files a/MPK61/Preset.syx and /dev/null differ diff --git a/MPK88/Preset.syx b/MPK88/Preset.syx deleted file mode 100644 index 4ce9e09..0000000 Binary files a/MPK88/Preset.syx and /dev/null differ diff --git a/Push/Preset.syx b/Push/Preset.syx deleted file mode 100644 index 5554320..0000000 Binary files a/Push/Preset.syx and /dev/null differ diff --git a/Push/Setup.syx b/Push/Setup.syx deleted file mode 100644 index 46b5814..0000000 Binary files a/Push/Setup.syx and /dev/null differ diff --git a/Push2/firmware/app_push2_stable_1.0.71.upgrade b/Push2/firmware/app_push2_stable_1.0.71.upgrade deleted file mode 100644 index 231a0a3..0000000 Binary files a/Push2/firmware/app_push2_stable_1.0.71.upgrade and /dev/null differ diff --git a/Roland_A_PRO/Preset.syx b/Roland_A_PRO/Preset.syx deleted file mode 100644 index 284fd5e..0000000 Binary files a/Roland_A_PRO/Preset.syx and /dev/null differ diff --git a/microKONTROL/Preset.syx b/microKONTROL/Preset.syx deleted file mode 100644 index f42a2f6..0000000 Binary files a/microKONTROL/Preset.syx and /dev/null differ