chore: clean repo and ignore runtime artifacts
This commit is contained in:
29
.gitignore
vendored
29
.gitignore
vendored
@@ -95,4 +95,31 @@ nul
|
||||
*.sample_embeddings.json
|
||||
|
||||
# AbletonMCP_AI generated audio
|
||||
AppData/
|
||||
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
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
46
AbletonMCP_AI_BAK_20260328_200801/.gitignore
vendored
46
AbletonMCP_AI_BAK_20260328_200801/.gitignore
vendored
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 <archivo_de_audio>")
|
||||
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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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},
|
||||
}
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 <directorio_de_samples>")
|
||||
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)")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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 <library_dir> [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")
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" <<EOF
|
||||
export ANTHROPIC_BASE_URL='https://coding-intl.dashscope.aliyuncs.com/apps/anthropic'
|
||||
export ANTHROPIC_AUTH_TOKEN='sk-sp-e87cea7b587c4af09e465726b084f41b'
|
||||
export GLM_MODEL='glm-5'
|
||||
export CODEX_MODEL='gpt-5.4'
|
||||
export TELEGRAM_BOT_TOKEN='8444660361:AAECCo6oon0dbnQMzgaanZntYFOLgcZrcJ4'
|
||||
export TELEGRAM_CHAT_ID='692714536'
|
||||
export CODEX_HOME='/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/wsl_runtime/codex_home'
|
||||
export GLM_AGENTS_FILE='/mnt/c/ProgramData/Ableton/Live 12 Suite/Resources/MIDI Remote Scripts/AbletonMCP_AI/automation/glm_agents.team.json'
|
||||
export POLL_SECONDS='30'
|
||||
export WATCH='1'
|
||||
export CONTINUE_ON_ERROR='1'
|
||||
EOF
|
||||
|
||||
if [[ -n "$OPENAI_API_KEY_VALUE" ]]; then
|
||||
printf "export OPENAI_API_KEY='%s'\n" "$OPENAI_API_KEY_VALUE" >> "$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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 <<EOF
|
||||
You are running as the GLM worker on this Linux repository.
|
||||
|
||||
Repository root:
|
||||
$PROJECT_ROOT
|
||||
|
||||
Task file to follow exactly:
|
||||
$TASK_FILE
|
||||
|
||||
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:
|
||||
$REPORT_FILE
|
||||
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.
|
||||
EOF
|
||||
)
|
||||
|
||||
ARGS=(
|
||||
-p
|
||||
--dangerously-skip-permissions
|
||||
--effort max
|
||||
--model "$GLM_MODEL"
|
||||
--add-dir "$PROJECT_ROOT"
|
||||
)
|
||||
|
||||
if [[ -f "$GLM_AGENTS_FILE" ]]; then
|
||||
AGENTS_JSON="$(cat "$GLM_AGENTS_FILE")"
|
||||
ARGS+=(--agents "$AGENTS_JSON")
|
||||
fi
|
||||
|
||||
notify "GLM worker started: $(basename "$TASK_FILE")"
|
||||
|
||||
if ! printf '%s\n' "$PROMPT" | claude "${ARGS[@]}" 2>&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"
|
||||
@@ -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
|
||||
@@ -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 <<EOF
|
||||
deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $codename stable
|
||||
EOF
|
||||
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
fi
|
||||
|
||||
if ! groups "$USER" | grep -q '\bdocker\b'; then
|
||||
log_info "Adding user $USER to docker group..."
|
||||
sudo usermod -aG docker "$USER"
|
||||
log_warn "A new login session may be needed for docker group membership."
|
||||
fi
|
||||
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
}
|
||||
|
||||
install_python() {
|
||||
log_info "Checking Python installation..."
|
||||
|
||||
if command -v python3 >/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 "$@"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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" <<EOF
|
||||
PROJECT_PATH='$PROJECT_ROOT'
|
||||
TZ='America/Buenos_Aires'
|
||||
POSTGRES_USER='postgres'
|
||||
POSTGRES_PASSWORD='$(generate_secret 16)'
|
||||
POSTGRES_BOOTSTRAP_DB='postgres'
|
||||
POSTGRES_PORT='5432'
|
||||
GITEA_DOMAIN='localhost'
|
||||
GITEA_ROOT_URL='http://localhost:3000'
|
||||
GITEA_HTTP_PORT='3000'
|
||||
GITEA_SSH_DOMAIN='localhost'
|
||||
GITEA_SSH_PORT='222'
|
||||
GITEA_ADMIN_USER='giteaadmin'
|
||||
GITEA_ADMIN_PASSWORD='$(generate_secret 16)'
|
||||
GITEA_ADMIN_EMAIL='admin@localhost'
|
||||
GITEA_DB_NAME='gitea'
|
||||
GITEA_SECURITY_INSTALL_LOCK='true'
|
||||
GITEA_OFFLINE_MODE='true'
|
||||
REDIS_PASSWORD='$(generate_secret 16)'
|
||||
REDIS_PORT='6379'
|
||||
N8N_HOST='localhost'
|
||||
N8N_PORT='5678'
|
||||
N8N_PATH='/'
|
||||
N8N_WEBHOOK_URL='http://localhost:5678/'
|
||||
N8N_EDITOR_BASE_URL='http://localhost:5678'
|
||||
N8N_DB_NAME='n8n'
|
||||
N8N_ENCRYPTION_KEY='$(generate_secret 32)'
|
||||
N8N_BASIC_AUTH_ACTIVE='true'
|
||||
N8N_BASIC_AUTH_USER='admin'
|
||||
N8N_BASIC_AUTH_PASSWORD='$(generate_secret 16)'
|
||||
N8N_HOST_ALLOW_LIST='localhost,127.0.0.1'
|
||||
N8N_EXECUTIONS_MODE='regular'
|
||||
N8N_LOG_LEVEL='info'
|
||||
N8N_DIAGNOSTICS_ENABLED='false'
|
||||
N8N_VERSION_NOTIFICATIONS_ENABLED='false'
|
||||
N8N_COOKIE_POLICY='lax'
|
||||
COMPOSE_PROJECT_NAME='abletonmcp'
|
||||
EOF
|
||||
chmod 600 "$DOCKER_ENV_FILE"
|
||||
}
|
||||
|
||||
ensure_runner_env() {
|
||||
if [[ -f "$RUNNER_ENV_FILE" ]]; then
|
||||
log_info "Runner env already exists: $RUNNER_ENV_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
log_step "Generating runner env"
|
||||
cat > "$RUNNER_ENV_FILE" <<EOF
|
||||
export ANTHROPIC_BASE_URL=''
|
||||
export ANTHROPIC_AUTH_TOKEN=''
|
||||
export GLM_MODEL='glm-5'
|
||||
export GLM_API_KEY=''
|
||||
export CODEX_MODEL='gpt-5.4'
|
||||
export TELEGRAM_BOT_TOKEN=''
|
||||
export TELEGRAM_CHAT_ID=''
|
||||
export CODEX_HOME='$AUTOMATION_DIR/wsl_runtime/codex_home'
|
||||
export GLM_AGENTS_FILE='$AUTOMATION_DIR/glm_agents.team.json'
|
||||
export POLL_SECONDS='30'
|
||||
export WATCH='1'
|
||||
export CONTINUE_ON_ERROR='1'
|
||||
EOF
|
||||
chmod 600 "$RUNNER_ENV_FILE"
|
||||
}
|
||||
|
||||
main() {
|
||||
log_info "Preparing AbletonMCP_AI WSL stack"
|
||||
ensure_dirs
|
||||
ensure_symlink
|
||||
write_docker_env
|
||||
ensure_runner_env
|
||||
echo
|
||||
log_info "Files ready:"
|
||||
echo " - $DOCKER_ENV_FILE"
|
||||
echo " - $RUNNER_ENV_FILE"
|
||||
echo
|
||||
log_info "Next:"
|
||||
echo " 1. Review tokens in $RUNNER_ENV_FILE"
|
||||
echo " 2. Review service passwords in $DOCKER_ENV_FILE"
|
||||
echo " 3. Run ./install.sh if Docker is not installed"
|
||||
echo " 4. Run ./start.sh"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,143 +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_error() { echo -e "${RED}[ERROR]${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)"
|
||||
RUNNER_ENV_FILE="$AUTOMATION_DIR/wsl.local.env"
|
||||
DOCKER_ENV_FILE="$WSL_DIR/.env"
|
||||
COMPOSE_FILE="$WSL_DIR/docker-compose.yml"
|
||||
RUNTIME_DIR="$AUTOMATION_DIR/wsl_runtime"
|
||||
LOGS_DIR="$RUNTIME_DIR/logs"
|
||||
PID_DIR="$RUNTIME_DIR/pids"
|
||||
START_QUEUE_RUNNER="${START_QUEUE_RUNNER:-1}"
|
||||
|
||||
mkdir -p "$LOGS_DIR" "$PID_DIR"
|
||||
|
||||
if [[ -f "$RUNNER_ENV_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$RUNNER_ENV_FILE"
|
||||
fi
|
||||
|
||||
if [[ -f "$DOCKER_ENV_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a
|
||||
source "$DOCKER_ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
compose_cmd() {
|
||||
docker compose --env-file "$DOCKER_ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
check_prerequisites() {
|
||||
log_step "Checking prerequisites"
|
||||
command -v docker >/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 "$@"
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
MPD32/Preset.syx
BIN
MPD32/Preset.syx
Binary file not shown.
BIN
MPK25/Preset.syx
BIN
MPK25/Preset.syx
Binary file not shown.
BIN
MPK49/Preset.syx
BIN
MPK49/Preset.syx
Binary file not shown.
BIN
MPK61/Preset.syx
BIN
MPK61/Preset.syx
Binary file not shown.
BIN
MPK88/Preset.syx
BIN
MPK88/Preset.syx
Binary file not shown.
BIN
Push/Preset.syx
BIN
Push/Preset.syx
Binary file not shown.
BIN
Push/Setup.syx
BIN
Push/Setup.syx
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user