diff --git a/README.md b/README.md index 176765c..a664e5b 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ A full-featured [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) ## Overview -**ableton-mcp** bridges AI assistants and Ableton Live by exposing **124 MCP tools** covering every major area of the Live API: transport control, track mixing, clip launching and editing, MIDI note manipulation, device parameter control, scene management, view selection, and real-time property listeners. +**ableton-mcp** bridges AI assistants and Ableton Live by exposing **160+ MCP tools** covering the full AbletonOSC API: transport, mixing, clips, MIDI notes, devices, scenes, return tracks, browser, groove pool, view navigation, and real-time listeners. -The server communicates with Ableton Live via [AbletonOSC](https://github.com/ideoforms/AbletonOSC) — a MIDI Remote Script that runs inside Live and exposes its internal API over OSC/UDP. +The server communicates with Ableton Live via AbletonOSC — a MIDI Remote Script that runs inside Live and exposes its internal API over OSC/UDP. ``` Claude (AI) ──MCP tools──▶ ableton-mcp ──OSC/UDP──▶ AbletonOSC ──Live Object Model──▶ Ableton Live @@ -17,8 +17,8 @@ Claude (AI) ──MCP tools──▶ ableton-mcp ──OSC/UDP──▶ AbletonO ## Requirements - **Ableton Live 11 or later** -- **Python 3.10+** (tested with 3.10.6 via pyenv) -- **AbletonOSC** installed as a MIDI Remote Script (see below) +- **Python 3.10+** +- **AbletonOSC** installed as a MIDI Remote Script --- @@ -26,101 +26,77 @@ Claude (AI) ──MCP tools──▶ ableton-mcp ──OSC/UDP──▶ AbletonO ### 1. Install AbletonOSC in Ableton Live -1. Download or clone [AbletonOSC](https://github.com/ideoforms/AbletonOSC) -2. Copy the `AbletonOSC` folder into your Ableton User Library Remote Scripts directory: +1. Copy the `AbletonOSC` folder into your Ableton User Library Remote Scripts directory: - **Windows:** `%USERPROFILE%\Documents\Ableton\User Library\Remote Scripts` - **macOS:** `~/Music/Ableton/User Library/Remote Scripts` -3. Restart Ableton Live -4. Open **Preferences → Link, Tempo & MIDI → MIDI** and set one of the Control Surface slots to **AbletonOSC** +2. Restart Ableton Live +3. Open **Preferences → Link, Tempo & MIDI → MIDI** and set one of the Control Surface slots to **AbletonOSC** -AbletonOSC will now listen on **UDP port 11000** and reply on **port 11001**. +AbletonOSC listens on **UDP port 11000** and replies on **port 11001**. ### 2. Install ableton-mcp ```bash -# Clone the repo (rename the folder after cloning) git clone https://github.com/your-user/ableton-mcp cd ableton-mcp -# Create and activate a virtual environment python -m venv .venv .venv\Scripts\activate # Windows # source .venv/bin/activate # macOS/Linux -# Install pip install -e . ``` -### 3. Configure Claude Desktop +### 3. Configure Claude Code -Add the server to your Claude Desktop configuration file: - -**Windows:** `%APPDATA%\Claude\claude_desktop_config.json` -**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` +Add to `~/.claude/settings.json`: ```json { "mcpServers": { - "ableton": { - "command": "C:\\path\\to\\ableton-mcp\\.venv\\Scripts\\ableton-mcp.exe" + "ableton-mcp": { + "command": "C:\\path\\to\\ableton-mcp\\.venv\\Scripts\\ableton-mcp.exe", + "args": [] } } } ``` -Restart Claude Desktop. The Ableton Live tools will appear automatically when a Live session is open. +Restart Claude Code. Tools appear automatically when a Live session is open. --- ## Configuration -All settings can be overridden via environment variables: - | Variable | Default | Description | |---|---|---| | `ABLETON_HOST` | `127.0.0.1` | AbletonOSC host address | -| `ABLETON_SEND_PORT` | `11000` | Port to send OSC commands to Live | -| `ABLETON_RECEIVE_PORT` | `11001` | Port to receive OSC replies from Live | +| `ABLETON_SEND_PORT` | `11000` | Port to send OSC to Live | +| `ABLETON_RECEIVE_PORT` | `11001` | Port to receive OSC from Live | | `ABLETON_TIMEOUT` | `5.0` | Query timeout in seconds | -Example (Claude Desktop config with custom port): -```json -{ - "mcpServers": { - "ableton": { - "command": "C:\\path\\to\\.venv\\Scripts\\ableton-mcp.exe", - "env": { - "ABLETON_TIMEOUT": "10.0" - } - } - } -} -``` - --- ## Tool Reference -### System (7 tools) +### System (5 tools) | Tool | Description | |---|---| | `system_test_connection` | Ping AbletonOSC; returns `{connected, latency_ms}` | | `system_get_version` | Get Ableton Live version `{major, minor}` | -| `system_get_cpu_usage` | Get average CPU process usage (0.0–1.0) | +| `system_get_cpu_usage` | Average CPU process usage (0.0–1.0) | | `system_show_message` | Display a message in the Ableton status bar | -| `system_get_log_level` | Get current AbletonOSC log level | -| `system_set_log_level` | Set log level: `debug` / `info` / `warning` / `error` / `critical` | | `system_reload_api` | Hot-reload all AbletonOSC modules | --- -### Song / Transport (48 tools) +### Song / Transport (38 tools) #### Playback | Tool | Description | |---|---| -| `song_get_state` | Full snapshot: tempo, is_playing, loop, time sig, track/scene counts, groove, link, scale… | +| `song_get_state` | Full snapshot: tempo, is_playing, loop, time sig, track/scene/return counts, groove | | `song_start_playing` | Start playback from current position | | `song_stop_playing` | Stop playback | | `song_continue_playing` | Resume without resetting to start | @@ -132,6 +108,9 @@ Example (Claude Desktop config with custom port): |---|---| | `song_get_tempo` / `song_set_tempo` | Get/set BPM (20–300) | | `song_get_time` / `song_set_time` | Get/jump playhead position in beats | +| `song_jump_by` | Move playhead by ±N beats relative to current position | +| `song_jump_to_next_cue` | Jump to next cue point | +| `song_jump_to_prev_cue` | Jump to previous cue point | | `song_get_time_signature` / `song_set_time_signature` | Get/set numerator + denominator | #### Loop @@ -163,38 +142,20 @@ Example (Claude Desktop config with custom port): |---|---| | `song_undo` / `song_redo` | Undo/redo | -#### Tracks +#### Tracks & Scenes | Tool | Description | |---|---| | `song_get_tracks` | List all tracks with index and name | -| `song_create_audio_track` | Create audio track at index | -| `song_create_midi_track` | Create MIDI track at index | -| `song_create_return_track` | Create return/aux track | -| `song_delete_track` / `song_delete_return_track` | Delete track | -| `song_duplicate_track` | Duplicate a track | - -#### Scenes -| Tool | Description | -|---|---| +| `song_get_return_tracks` | List all return tracks with index and name | +| `song_create_audio_track` / `song_create_midi_track` / `song_create_return_track` | Create tracks | +| `song_delete_track` / `song_delete_return_track` / `song_duplicate_track` | Delete/duplicate tracks | | `song_get_scenes` | List all scenes with index and name | | `song_create_scene` / `song_delete_scene` / `song_duplicate_scene` | Scene CRUD | -| `song_capture_and_insert_scene` | Capture session state as a new scene | #### Cue Points | Tool | Description | |---|---| -| `song_get_cue_points` | List all cue points | -| `song_jump_to_cue` | Jump to cue by index or name | -| `song_add_or_delete_cue` | Toggle cue at current playhead | -| `song_set_cue_name` | Rename a cue point | - -#### Scale & Link -| Tool | Description | -|---|---| -| `song_set_root_note` | Set root note (0=C … 11=B) | -| `song_set_scale_name` | Set scale name e.g. `"Major"`, `"Dorian"` | -| `song_set_ableton_link` | Enable/disable Ableton Link | -| `song_export_structure` | Export full session structure to JSON file | +| `song_get_cue_points` | List all cue points `{index, name, time}` | --- @@ -202,52 +163,87 @@ Example (Claude Desktop config with custom port): | Tool | Description | |---|---| -| `track_get_info` | All track properties: name, volume, pan, mute, solo, arm, type, color, monitoring, meter slots, device count | +| `track_get_info` | All properties: name, volume, pan, mute, solo, arm, type, color, monitoring, slots, device count | | `track_set_volume` | Volume (0.0=−∞, 0.85≈0 dB, 1.0=+6 dB) | | `track_set_pan` | Panning (−1.0=left, 0.0=center, +1.0=right) | -| `track_set_mute` | Mute / unmute | -| `track_set_solo` | Solo / unsolo | -| `track_set_arm` | Arm / disarm for recording | +| `track_set_mute` / `track_set_solo` / `track_set_arm` | Mute/solo/arm | | `track_get_send` / `track_set_send` | Get/set send level to a return track | | `track_set_name` | Rename a track | | `track_set_color` | Set track color by palette index (0–69) | | `track_stop_clips` | Stop all clips on a track | -| `track_set_monitoring` | Monitoring state: 0=Auto, 1=In, 2=Off | +| `track_set_monitoring` | Monitoring: 0=Auto, 1=In, 2=Off | | `track_set_fold` | Fold/unfold a group track | -| `track_get_clips` | List session clips: index, name, length, color | -| `track_get_arrangement_clips` | List arrangement clips: index, name, length, start_time | -| `track_get_devices` | List devices: index, name, type, class_name | -| `track_get_meter` | Output meter levels: left, right, level | -| `track_delete_device` | Remove a device from the chain | -| `track_get_available_input_routing_types` | List available input routing options | -| `track_set_input_routing_type` / `track_set_output_routing_type` | Set I/O routing | +| `track_get_clips` | Session clips: index, name, length, color, is_playing | +| `track_get_arrangement_clips` | Arrangement clips: index, name, length, start_time | +| `track_get_devices` | Devices: index, name, type, class_name, can_have_chains | +| `track_get_meter` | Output meter: level, left, right, peak_left, peak_right | +| `track_delete_device` / `track_duplicate_device` | Remove/duplicate device | +| `track_get_input_routing` | Current input type + channel + available options | +| `track_set_input_routing_type` / `track_set_input_routing_channel` | Set input routing | +| `track_get_output_routing` | Current output type + channel + available options | +| `track_set_output_routing_type` / `track_set_output_routing_channel` | Set output routing | --- -### Clips (19 tools) +### Return Tracks (11 tools) | Tool | Description | |---|---| -| `clip_fire` | Launch a clip | -| `clip_stop` | Stop a playing clip | -| `clip_get_info` | All clip properties: name, length, playing state, loop, markers, pitch, gain, launch mode, warp, color | -| `clip_set_name` | Rename a clip | -| `clip_set_color` | Set clip color by palette index (0–69) | -| `clip_set_gain` | Set clip gain in dB | -| `clip_set_muted` | Mute/unmute a clip | -| `clip_set_looping` | Enable/disable loop | -| `clip_set_loop_points` | Set loop start + end in beats | -| `clip_set_markers` | Set start + end markers in beats | -| `clip_set_pitch` | Transpose: semitones (−48 to +48) and cents (−50 to +50) | +| `return_track_get_info` | All properties: name, mute, solo, color, volume, panning | +| `return_track_set_volume` / `return_track_set_panning` | Volume/pan | +| `return_track_set_mute` / `return_track_set_solo` | Mute/solo | +| `return_track_set_name` / `return_track_set_color` | Rename/recolor | +| `return_track_get_send` / `return_track_set_send` | Inter-return sends | +| `return_track_get_meter` | Output meter: level, left, right | +| `return_track_get_devices` | List devices on the return track | + +--- + +### Clips (31 tools) + +#### Playback & Properties +| Tool | Description | +|---|---| +| `clip_fire` / `clip_stop` | Launch/stop a clip | +| `clip_get_info` | All properties: name, length, playing state, loop, markers, pitch, gain, launch mode | +| `clip_set_name` / `clip_set_color` | Rename/recolor | +| `clip_set_gain` | Clip gain in dB | +| `clip_set_muted` | Mute/unmute | +| `clip_set_looping` / `clip_set_loop_points` | Enable loop and set start/end | +| `clip_set_markers` | Set clip start/end markers | +| `clip_set_pitch` | Transpose: semitones (−48…+48) and cents (−50…+50) | | `clip_set_warp_mode` | Warp mode: 0=Beats, 1=Tones, 2=Texture, 3=Re-Pitch, 4=Complex, 5=Complex Pro | | `clip_set_warping` | Enable/disable warping | -| `clip_set_launch_mode` | Launch mode: 0=Trigger, 1=Gate, 2=Toggle, 3=Repeat | -| `clip_set_launch_quantization` | Per-clip launch quantization (0–14) | -| `clip_set_legato` | Enable/disable legato mode | -| `clip_duplicate_loop` | Double loop length by duplicating content | -| `clip_get_notes` | Get MIDI notes (filterable by pitch range + time range) | -| `clip_add_notes` | Add MIDI notes: `[{pitch, start, duration, velocity, mute}, …]` | -| `clip_remove_notes` | Remove MIDI notes by pitch/time range | +| `clip_set_launch_mode` | 0=Trigger, 1=Gate, 2=Toggle, 3=Repeat | +| `clip_set_launch_quantization` | Per-clip quantization (0–14) | +| `clip_set_legato` | Enable/disable legato | +| `clip_set_velocity_amount` | Scale note velocities (0.0–1.0) | +| `clip_set_ram_mode` | Enable/disable RAM mode for audio clips | +| `clip_duplicate_loop` | Double loop by duplicating content | +| `clip_quantize` | Quantize MIDI notes (quantization enum + strength 0.0–1.0) | + +#### MIDI Notes +| Tool | Description | +|---|---| +| `clip_get_notes` | Notes filtered by pitch/time range: `{pitch, start, duration, velocity, mute}` | +| `clip_get_notes_extended` | Extended notes with note_id, release_velocity, probability | +| `clip_add_notes` | Add notes: `[{pitch, start, duration, velocity, mute}, …]` | +| `clip_apply_note_modifications` | Modify existing notes by note_id | +| `clip_remove_notes` | Remove notes in pitch/time range | + +#### Automation Envelopes +| Tool | Description | +|---|---| +| `clip_get_automation_envelope` | Get breakpoints `{time, value}` for a device param | +| `clip_create_automation_envelope` | Create envelope for a device param | +| `clip_clear_envelope` | Clear one param's envelope | +| `clip_clear_all_envelopes` | Clear all envelopes in a clip | + +#### Warp & Groove +| Tool | Description | +|---|---| +| `clip_get_warp_markers` | Warp markers: `{beat_time, sample_time}` | +| `clip_get_groove` / `clip_set_groove` | Get/assign groove by name | --- @@ -255,52 +251,84 @@ Example (Claude Desktop config with custom port): | Tool | Description | |---|---| -| `clip_slot_get_info` | Slot state: has_clip, is_playing, is_triggered, will_record, is_group_slot | -| `clip_slot_fire` | Trigger a slot (launches clip or starts recording if empty) | -| `clip_slot_stop` | Stop a slot | -| `clip_slot_create_clip` | Create a new empty MIDI clip with a given length in beats | +| `clip_slot_get_info` | State: has_clip, is_playing, is_triggered, will_record, is_group_slot | +| `clip_slot_fire` / `clip_slot_stop` | Trigger/stop a slot | +| `clip_slot_create_clip` | Create empty MIDI clip with given length in beats | | `clip_slot_delete_clip` | Delete the clip in a slot | -| `clip_slot_duplicate_to` | Copy clip from one slot to another | -| `clip_slot_set_stop_button` | Enable/disable the stop button for a slot | +| `clip_slot_duplicate_to` | Copy clip to another slot | +| `clip_slot_set_stop_button` | Enable/disable slot stop button | --- -### Scenes (7 tools) +### Scenes (9 tools) | Tool | Description | |---|---| | `scene_fire` | Launch a scene by index | -| `scene_fire_selected` | Launch the currently selected scene | -| `scene_get_info` | All scene properties: name, color, tempo, tempo_enabled, time sig, is_empty, is_triggered | -| `scene_set_name` | Rename a scene | -| `scene_set_color` | Set scene color by palette index (0–69) | -| `scene_set_tempo` | Override tempo for a scene (with enable toggle) | -| `scene_set_time_signature` | Override time signature for a scene | +| `scene_fire_as_selected` | Fire scene respecting follow actions | +| `scene_get_info` | All properties: name, color, tempo, time sig, is_empty, is_triggered | +| `scene_get_num_clips` | Number of clips in the scene | +| `scene_get_clip_slots` | Per-slot occupancy: `{slot_index, has_clip}` | +| `scene_set_name` / `scene_set_color` | Rename/recolor | +| `scene_set_tempo` | Override tempo (with enable toggle) | +| `scene_set_time_signature` | Override time signature | --- -### Devices (6 tools) +### Devices (15 tools) | Tool | Description | |---|---| -| `device_get_info` | Device info: name, type (audio_effect / instrument / midi_effect), class_name, num_parameters | -| `device_get_parameters` | All parameters: index, name, value, min, max, is_quantized | -| `device_get_parameter` | Single parameter: name, value, value_string (display value) | -| `device_set_parameter` | Set a parameter value (normalized 0.0–1.0) | -| `device_set_parameters_bulk` | Set multiple parameters in one call: `[{index, value}, …]` | -| `device_map_midi_cc` | Map a MIDI CC to a device parameter | +| `device_get_info` | Name, type, class_name, num_parameters, is_active | +| `device_get_is_active` / `device_set_is_active` | Get/set bypass state | +| `device_get_parameters` | All params: index, name, value, min, max, default_value, is_quantized | +| `device_get_parameter` | Single param: name, value, min, max, default_value, value_string | +| `device_set_parameter` | Set a parameter value (0.0–1.0 normalized) | +| `device_set_parameters_bulk` | Set multiple params: `[{index, value}, …]` | +| `device_randomize_macros` | Randomize rack macro knobs | +| `device_get_chains` | Rack chains: index, name, num_devices, device names | +| `device_get_drum_pads` | Drum Rack pads: index, name, note, mute, solo | +| `device_set_drum_pad_mute` / `device_set_drum_pad_solo` | Mute/solo a drum pad | +| `device_chain_get_parameter` | Get a param value inside a rack chain | +| `device_chain_set_parameter` | Set a param value inside a rack chain | --- -### View / Selection (5 tools) +### View / Navigation (8 tools) | Tool | Description | |---|---| -| `view_get_selection` | Current selection: selected_track, selected_scene, selected_clip, selected_device | -| `view_set_selected_track` | Select a track in the Ableton UI | -| `view_set_selected_scene` | Select a scene in the Ableton UI | -| `view_set_selected_clip` | Select a clip in the Ableton UI | -| `view_set_selected_device` | Select a device in the Ableton UI | +| `view_get_selection` | Selected track, scene, clip, and device | +| `view_get_focused_document_view` | Active view name (e.g. `'Session'`, `'Arranger'`) | +| `view_set_selected_track` | Select a track | +| `view_set_selected_scene` | Select a scene | +| `view_set_selected_clip` | Select a clip | +| `view_show_clip_detail` | Show clip detail panel | +| `view_show_device_detail` | Show device detail panel | +| `view_focus_browser` | Focus the browser panel | + +--- + +### Browser (6 tools) + +| Tool | Description | +|---|---| +| `browser_get_items` | List items in a category: `audio_effects` / `instruments` / `midi_effects` / `samples` / `sounds` / `clips` / `packs` / `plugins` | +| `browser_load_item` | Load an item into the selected track/slot (pass path parts as args) | +| `browser_preview_item` | Preview a sample or preset | +| `browser_stop_preview` | Stop browser preview playback | +| `browser_get_hotswap_target` | Name of device targeted for hotswap | +| `browser_begin_hotswap` | Begin hotswap mode for a device | + +--- + +### Groove Pool (3 tools) + +| Tool | Description | +|---|---| +| `groove_get_all` | List all groove names in the groove pool | +| `groove_get_amount` | Get groove intensity (0.0–1.0) by name | +| `groove_set_amount` | Set groove intensity (0.0–1.0) by name | --- @@ -310,7 +338,7 @@ Subscribe to property changes from Ableton Live and poll them at any time. | Tool | Description | |---|---| -| `listener_start` | Register a listener for a property; returns the OSC response address | +| `listener_start` | Register a listener for a property | | `listener_stop` | Unregister a listener | | `listener_get_events` | Drain queued events (up to `max_events`); each event is a list of OSC args | @@ -328,10 +356,6 @@ Subscribe to property changes from Ableton Live and poll them at any time. | `clip` | any read/write property | requires `track_index` + `clip_index` | | `clip_slot` | any property | requires `track_index` + `clip_index` | -**Example usage with Claude:** -> "Watch the beat and tell me the bar number every 4 bars" -> Claude calls `listener_start("song", "beat")`, then periodically `listener_get_events("song", "beat")` - --- ## Example Prompts @@ -342,14 +366,17 @@ Play the song from bar 3 at 128 BPM with the metronome on. Create a 4-bar MIDI clip on track 0, slot 1, and add a C minor chord (C3, Eb3, G3) on beats 1 and 3 with velocity 90. -Set every device on track 2 to a random-ish sound: -randomize all parameters whose name contains "Filter". +Load a reverb from the browser onto the first return track and set the +wet/dry to 50%. -Loop scene 4 forever: set scene 4's clips to looping and fire the scene. +Get all drum pad names on track 0's Drum Rack, then mute every pad +that isn't a kick or snare. -Solo track 3, mute tracks 1 and 2, and set track 3's reverb send to 0.4. +Solo track 3, set its reverb send to 0.4, and watch the output meter +until it clips. -Name all empty scenes "—" and delete any track with no clips. +Set the groove pool's first groove to 80% intensity and assign it to +all clips on track 1. ``` --- @@ -358,42 +385,43 @@ Name all empty scenes "—" and delete any track with no clips. ``` src/ableton_mcp/ -├── server.py # FastMCP app; registers all tool modules -├── osc_client.py # Thread-safe OSCClient singleton -│ # cmd() – fire-and-forget -│ # query() – synchronous request/response -│ # start_listener() / drain_listener() – real-time events -├── config.py # Port/host/timeout from env vars +├── server.py # FastMCP app; registers all tool modules +├── osc_client.py # Thread-safe OSCClient singleton +│ # cmd() – fire-and-forget +│ # query() – synchronous request/response +│ # start_listener() / drain_listener() – real-time events +├── config.py # Port/host/timeout from env vars └── tools/ - ├── song.py # Transport, tempo, loop, scenes, tracks, cues, scale, Link - ├── track.py # Mixing, routing, devices list, meter - ├── clip.py # Playback, MIDI notes, pitch, warp, loop, launch - ├── clip_slot.py # Slot-level create/fire/delete/duplicate - ├── scene.py # Scene fire, tempo/time-sig overrides - ├── device.py # Parameter get/set (individual + bulk), MIDI CC mapping - ├── view.py # Selected track/scene/clip/device - ├── system.py # Connectivity, version, CPU, status bar, logging - └── listener.py # Real-time property change subscriptions + ├── song.py # Transport, tempo, loop, scenes, tracks, cues + ├── track.py # Mixing, routing, devices list, meter + ├── clip.py # Playback, MIDI notes, pitch, warp, loop, launch, envelopes + ├── clip_slot.py # Slot-level create/fire/delete/duplicate + ├── scene.py # Scene fire, info, clip slots, tempo/time-sig overrides + ├── device.py # Parameters (individual + bulk), chains, drum pads + ├── return_track.py# Return/aux track mixing and devices + ├── browser.py # Content library browsing, load, preview, hotswap + ├── groove.py # Groove pool listing and amount control + ├── view.py # Selected track/scene/clip, panel navigation + ├── system.py # Connectivity, version, CPU, status bar + └── listener.py # Real-time property change subscriptions ``` -**OSC communication** uses `python-osc` directly against AbletonOSC's full 200+ endpoint API. The `OSCClient` singleton runs a `ThreadingOSCUDPServer` on the receive port in a daemon thread. Queries use `threading.Event` for synchronous blocking with configurable timeout. Listeners store events in per-address `collections.deque` instances for thread-safe polling. - --- ## Troubleshooting **`system_test_connection` returns `connected: false`** -- Confirm AbletonOSC is enabled in Ableton → Preferences → Control Surfaces -- Make sure a Live project is open (AbletonOSC only activates with an open set) -- Check that no firewall is blocking UDP 11000/11001 on localhost +- Confirm AbletonOSC is enabled in Preferences → Control Surfaces +- A Live project must be open (AbletonOSC only activates with an open set) +- Check that no firewall blocks UDP 11000/11001 on localhost -**Timeout errors on queries** +**Timeout errors** - Increase `ABLETON_TIMEOUT` env var (default 5 s) -- Some properties (e.g. `song_get_state`) issue many sequential OSC queries; slow machines may need a higher timeout +- `song_get_state` issues many sequential queries; slow machines may need 10 s+ **`No module named 'ableton_mcp'`** -- Make sure you installed with `pip install -e .` inside the correct `.venv` -- The Claude Desktop config `command` path must point to the `.venv` executable, not the system Python +- Install with `pip install -e .` inside the correct `.venv` +- The MCP config `command` path must point to the `.venv` executable --- diff --git a/src/ableton_mcp/server.py b/src/ableton_mcp/server.py index c0d48ca..90caa8f 100644 --- a/src/ableton_mcp/server.py +++ b/src/ableton_mcp/server.py @@ -12,6 +12,9 @@ from ableton_mcp.tools import ( device, view, listener, + return_track, + browser, + groove, ) mcp = FastMCP("ableton-mcp") @@ -25,6 +28,9 @@ scene.register(mcp) device.register(mcp) view.register(mcp) listener.register(mcp) +return_track.register(mcp) +browser.register(mcp) +groove.register(mcp) def main() -> None: diff --git a/src/ableton_mcp/tools/browser.py b/src/ableton_mcp/tools/browser.py new file mode 100644 index 0000000..f4130e6 --- /dev/null +++ b/src/ableton_mcp/tools/browser.py @@ -0,0 +1,45 @@ +"""MCP tools for Ableton Live browser / content library.""" + +from ableton_mcp.osc_client import get_client + +_CATEGORIES = ("audio_effects", "instruments", "midi_effects", "samples", "sounds", "clips", "packs", "plugins") + + +def register(mcp): + + @mcp.tool() + def browser_get_items(category: str) -> list: + """List items in a browser category. + category: audio_effects | instruments | midi_effects | samples | sounds | clips | packs | plugins. + Returns a flat list of item path strings.""" + if category not in _CATEGORIES: + raise ValueError(f"category must be one of: {', '.join(_CATEGORIES)}") + result = get_client().query(f"/live/browser/get/{category}") + return list(result) + + @mcp.tool() + def browser_load_item(*path_parts: str) -> None: + """Load a browser item into the currently selected track/slot. + Pass the item path as individual strings, e.g. browser_load_item('Instruments', 'Drift', 'Init').""" + get_client().cmd("/live/browser/load_item", *path_parts) + + @mcp.tool() + def browser_preview_item(*path_parts: str) -> None: + """Preview a browser item (plays a sample or preset preview).""" + get_client().cmd("/live/browser/preview_item", *path_parts) + + @mcp.tool() + def browser_stop_preview() -> None: + """Stop any currently playing browser preview.""" + get_client().cmd("/live/browser/stop_preview") + + @mcp.tool() + def browser_get_hotswap_target() -> str: + """Get the name of the device currently targeted for hotswap (empty if none).""" + result = get_client().query("/live/browser/get/hotswap_target") + return result[0] if result else "" + + @mcp.tool() + def browser_begin_hotswap(track_index: int, device_index: int) -> None: + """Begin hotswap mode for a device — subsequent browser_load_item calls replace it.""" + get_client().cmd("/live/browser/begin_hotswap", track_index, device_index) diff --git a/src/ableton_mcp/tools/clip.py b/src/ableton_mcp/tools/clip.py index 6452e2d..1c1a58c 100644 --- a/src/ableton_mcp/tools/clip.py +++ b/src/ableton_mcp/tools/clip.py @@ -114,11 +114,36 @@ def register(mcp): """Enable or disable legato mode for a clip.""" get_client().cmd("/live/clip/set/legato", track_index, clip_index, int(legato)) + @mcp.tool() + def clip_set_velocity_amount(track_index: int, clip_index: int, amount: float) -> None: + """Set clip velocity amount (0.0–1.0). Scales note velocities.""" + get_client().cmd("/live/clip/set/velocity_amount", track_index, clip_index, amount) + + @mcp.tool() + def clip_set_ram_mode(track_index: int, clip_index: int, enabled: bool) -> None: + """Enable or disable RAM mode for an audio clip.""" + get_client().cmd("/live/clip/set/ram_mode", track_index, clip_index, int(enabled)) + @mcp.tool() def clip_duplicate_loop(track_index: int, clip_index: int) -> None: """Double the clip loop length by duplicating its content.""" get_client().cmd("/live/clip/duplicate_loop", track_index, clip_index) + @mcp.tool() + def clip_quantize( + track_index: int, + clip_index: int, + quantization: int = 5, + strength: float = 1.0, + ) -> None: + """Quantize MIDI notes in a clip. + quantization: same enum as global (4=1 bar, 7=1/4, 9=1/8, 11=1/16, etc.) + strength: 0.0–1.0 (1.0 = fully quantized).""" + get_client().cmd( + "/live/clip/quantize", + track_index, clip_index, quantization, strength, + ) + # --- MIDI Notes --- @mcp.tool() @@ -148,6 +173,37 @@ def register(mcp): }) return notes + @mcp.tool() + def clip_get_notes_extended( + track_index: int, + clip_index: int, + pitch_start: int = 0, + pitch_span: int = 127, + time_start: float = 0.0, + time_span: float = 1000.0, + ) -> list: + """Get extended MIDI note data including note_id, release_velocity, and probability. + Returns list of {pitch, start, duration, velocity, mute, note_id, release_velocity, probability}.""" + result = get_client().query( + "/live/clip/get/notes_extended", + track_index, clip_index, + pitch_start, pitch_span, + time_start, time_span, + ) + notes = [] + for i in range(0, len(result), 8): + notes.append({ + "pitch": result[i], + "start": result[i + 1], + "duration": result[i + 2], + "velocity": result[i + 3], + "mute": bool(result[i + 4]), + "note_id": result[i + 5], + "release_velocity": result[i + 6], + "probability": result[i + 7], + }) + return notes + @mcp.tool() def clip_add_notes(track_index: int, clip_index: int, notes: list) -> None: """Add MIDI notes to a clip. notes: list of {pitch, start, duration, velocity, mute}. @@ -163,6 +219,24 @@ def register(mcp): ]) get_client().cmd("/live/clip/add/notes", *args) + @mcp.tool() + def clip_apply_note_modifications(track_index: int, clip_index: int, notes: list) -> None: + """Modify existing MIDI notes by note_id (use clip_get_notes_extended to get IDs). + notes: list of {note_id, pitch, start, duration, velocity, mute, release_velocity, probability}.""" + args = [track_index, clip_index] + for note in notes: + args.extend([ + int(note["note_id"]), + int(note["pitch"]), + float(note["start"]), + float(note["duration"]), + int(note["velocity"]), + int(note.get("mute", 0)), + int(note.get("release_velocity", 64)), + float(note.get("probability", 1.0)), + ]) + get_client().cmd("/live/clip/apply_note_modifications", *args) + @mcp.tool() def clip_remove_notes( track_index: int, @@ -179,3 +253,67 @@ def register(mcp): pitch_start, pitch_span, time_start, time_span, ) + + # --- Automation Envelopes --- + + @mcp.tool() + def clip_get_automation_envelope( + track_index: int, clip_index: int, device_index: int, param_index: int + ) -> list: + """Get automation envelope breakpoints for a device parameter in a clip. + Returns list of {time, value} breakpoints.""" + result = get_client().query( + "/live/clip/get/automation_envelope", + track_index, clip_index, device_index, param_index, + ) + return [ + {"time": result[i], "value": result[i + 1]} + for i in range(0, len(result), 2) + ] + + @mcp.tool() + def clip_create_automation_envelope( + track_index: int, clip_index: int, device_index: int, param_index: int + ) -> None: + """Create an automation envelope for a device parameter in a clip.""" + get_client().cmd( + "/live/clip/create_automation_envelope", + track_index, clip_index, device_index, param_index, + ) + + @mcp.tool() + def clip_clear_envelope( + track_index: int, clip_index: int, device_index: int, param_index: int + ) -> None: + """Clear the automation envelope for a specific device parameter in a clip.""" + get_client().cmd( + "/live/clip/clear_envelope", + track_index, clip_index, device_index, param_index, + ) + + @mcp.tool() + def clip_clear_all_envelopes(track_index: int, clip_index: int) -> None: + """Clear all automation envelopes in a clip.""" + get_client().cmd("/live/clip/clear_all_envelopes", track_index, clip_index) + + # --- Warp & Groove --- + + @mcp.tool() + def clip_get_warp_markers(track_index: int, clip_index: int) -> list: + """Get warp markers for an audio clip. Returns list of {beat_time, sample_time}.""" + result = get_client().query("/live/clip/get/warp_markers", track_index, clip_index) + return [ + {"beat_time": result[i], "sample_time": result[i + 1]} + for i in range(0, len(result), 2) + ] + + @mcp.tool() + def clip_get_groove(track_index: int, clip_index: int) -> str: + """Get the groove name assigned to a clip (empty string if none).""" + result = get_client().query("/live/clip/get/groove", track_index, clip_index) + return result[0] if result else "" + + @mcp.tool() + def clip_set_groove(track_index: int, clip_index: int, groove_name: str) -> None: + """Assign a groove to a clip by name (use groove_get_all for available names).""" + get_client().cmd("/live/clip/set/groove", track_index, clip_index, groove_name) diff --git a/src/ableton_mcp/tools/device.py b/src/ableton_mcp/tools/device.py index 2c9ac07..87f06dc 100644 --- a/src/ableton_mcp/tools/device.py +++ b/src/ableton_mcp/tools/device.py @@ -7,7 +7,7 @@ def register(mcp): @mcp.tool() def device_get_info(track_index: int, device_index: int) -> dict: - """Get info about a device. Returns {name, type, class_name, num_parameters}.""" + """Get info about a device. Returns {name, type, class_name, num_parameters, can_have_chains}.""" c = get_client() type_map = {1: "audio_effect", 2: "instrument", 4: "midi_effect"} raw_type = c.query("/live/device/get/type", track_index, device_index)[0] @@ -16,17 +16,29 @@ def register(mcp): "type": type_map.get(raw_type, raw_type), "class_name": c.query("/live/device/get/class_name", track_index, device_index)[0], "num_parameters": c.query("/live/device/get/num_parameters", track_index, device_index)[0], + "is_active": bool(c.query("/live/device/get/is_active", track_index, device_index)[0]), } + @mcp.tool() + def device_get_is_active(track_index: int, device_index: int) -> bool: + """Get whether a device is active (not bypassed).""" + return bool(get_client().query("/live/device/get/is_active", track_index, device_index)[0]) + + @mcp.tool() + def device_set_is_active(track_index: int, device_index: int, active: bool) -> None: + """Enable or bypass a device.""" + get_client().cmd("/live/device/set/is_active", track_index, device_index, int(active)) + @mcp.tool() def device_get_parameters(track_index: int, device_index: int) -> list: """Get all parameters of a device. - Returns list of {index, name, value, min, max, is_quantized, value_string}.""" + Returns list of {index, name, value, min, max, default_value, is_quantized}.""" c = get_client() names = c.query("/live/device/get/parameters/name", track_index, device_index) values = c.query("/live/device/get/parameters/value", track_index, device_index) mins = c.query("/live/device/get/parameters/min", track_index, device_index) maxs = c.query("/live/device/get/parameters/max", track_index, device_index) + defaults = c.query("/live/device/get/parameters/default_value", track_index, device_index) quantized = c.query("/live/device/get/parameters/is_quantized", track_index, device_index) return [ { @@ -35,18 +47,22 @@ def register(mcp): "value": v, "min": mn, "max": mx, + "default_value": dv, "is_quantized": bool(q), } - for i, (n, v, mn, mx, q) in enumerate(zip(names, values, mins, maxs, quantized)) + for i, (n, v, mn, mx, dv, q) in enumerate(zip(names, values, mins, maxs, defaults, quantized)) ] @mcp.tool() def device_get_parameter(track_index: int, device_index: int, param_index: int) -> dict: - """Get a single device parameter. Returns {name, value, min, max, value_string}.""" + """Get a single device parameter. Returns {name, value, min, max, default_value, value_string}.""" c = get_client() return { "name": c.query("/live/device/get/parameter/name", track_index, device_index, param_index)[0], "value": c.query("/live/device/get/parameter/value", track_index, device_index, param_index)[0], + "min": c.query("/live/device/get/parameter/min", track_index, device_index, param_index)[0], + "max": c.query("/live/device/get/parameter/max", track_index, device_index, param_index)[0], + "default_value": c.query("/live/device/get/parameter/default_value", track_index, device_index, param_index)[0], "value_string": c.query("/live/device/get/parameter/value_string", track_index, device_index, param_index)[0], } @@ -72,15 +88,87 @@ def register(mcp): get_client().cmd("/live/device/set/parameters/value", *args) @mcp.tool() - def device_map_midi_cc( + def device_randomize_macros(track_index: int, device_index: int) -> None: + """Randomize all macro knobs on a rack device.""" + get_client().cmd("/live/device/randomize_macros", track_index, device_index) + + @mcp.tool() + def device_get_chains(track_index: int, device_index: int) -> list: + """Get all chains in a rack device. Returns list of {index, name, num_devices, devices}.""" + c = get_client() + num = c.query("/live/device/get/num_chains", track_index, device_index)[0] + if num == 0: + return [] + names = c.query("/live/device/get/chains/name", track_index, device_index) + num_devices = c.query("/live/device/get/chains/num_devices", track_index, device_index) + device_names = c.query("/live/device/get/chains/devices/name", track_index, device_index) + result = [] + dev_offset = 0 + for i, (name, nd) in enumerate(zip(names, num_devices)): + chain_devices = list(device_names[dev_offset:dev_offset + nd]) + dev_offset += nd + result.append({"index": i, "name": name, "num_devices": nd, "devices": chain_devices}) + return result + + @mcp.tool() + def device_get_drum_pads(track_index: int, device_index: int) -> list: + """Get all drum pads in a Drum Rack. Returns list of {index, name, note, mute, solo}.""" + c = get_client() + names = c.query("/live/device/get/drum_pads/name", track_index, device_index) + notes = c.query("/live/device/get/drum_pads/note", track_index, device_index) + mutes = c.query("/live/device/get/drum_pads/mute", track_index, device_index) + solos = c.query("/live/device/get/drum_pads/solo", track_index, device_index) + return [ + {"index": i, "name": n, "note": nt, "mute": bool(m), "solo": bool(s)} + for i, (n, nt, m, s) in enumerate(zip(names, notes, mutes, solos)) + ] + + @mcp.tool() + def device_set_drum_pad_mute( + track_index: int, device_index: int, pad_index: int, mute: bool + ) -> None: + """Mute or unmute a drum pad in a Drum Rack.""" + get_client().cmd( + "/live/device/set/drum_pads/mute", + track_index, device_index, pad_index, int(mute), + ) + + @mcp.tool() + def device_set_drum_pad_solo( + track_index: int, device_index: int, pad_index: int, solo: bool + ) -> None: + """Solo or unsolo a drum pad in a Drum Rack.""" + get_client().cmd( + "/live/device/set/drum_pads/solo", + track_index, device_index, pad_index, int(solo), + ) + + @mcp.tool() + def device_chain_get_parameter( track_index: int, device_index: int, + chain_index: int, + sub_device_index: int, param_index: int, - channel: int, - cc: int, - ) -> None: - """Map a MIDI CC to a device parameter. channel: 0–15, cc: 0–127.""" - get_client().cmd( - "/live/midimap/map_cc", - track_index, device_index, param_index, channel, cc, + ) -> float: + """Get a parameter value from a device inside a rack chain.""" + result = get_client().query( + "/live/device/chain/get/parameter/value", + track_index, device_index, chain_index, sub_device_index, param_index, + ) + return result[0] + + @mcp.tool() + def device_chain_set_parameter( + track_index: int, + device_index: int, + chain_index: int, + sub_device_index: int, + param_index: int, + value: float, + ) -> None: + """Set a parameter value on a device inside a rack chain.""" + get_client().cmd( + "/live/device/chain/set/parameter/value", + track_index, device_index, chain_index, sub_device_index, param_index, value, ) diff --git a/src/ableton_mcp/tools/groove.py b/src/ableton_mcp/tools/groove.py new file mode 100644 index 0000000..d3a3d88 --- /dev/null +++ b/src/ableton_mcp/tools/groove.py @@ -0,0 +1,23 @@ +"""MCP tools for Ableton Live groove pool.""" + +from ableton_mcp.osc_client import get_client + + +def register(mcp): + + @mcp.tool() + def groove_get_all() -> list: + """Get all grooves in the groove pool. Returns list of groove name strings.""" + result = get_client().query("/live/groove/get/grooves") + return list(result) + + @mcp.tool() + def groove_get_amount(groove_name: str) -> float: + """Get the amount (intensity) of a groove (0.0–1.0).""" + result = get_client().query("/live/groove/get/amount", groove_name) + return result[0] + + @mcp.tool() + def groove_set_amount(groove_name: str, amount: float) -> None: + """Set the amount (intensity) of a groove (0.0–1.0).""" + get_client().cmd("/live/groove/set/amount", groove_name, amount) diff --git a/src/ableton_mcp/tools/return_track.py b/src/ableton_mcp/tools/return_track.py new file mode 100644 index 0000000..e9cc451 --- /dev/null +++ b/src/ableton_mcp/tools/return_track.py @@ -0,0 +1,75 @@ +"""MCP tools for Ableton Live return/auxiliary track control.""" + +from ableton_mcp.osc_client import get_client + + +def register(mcp): + + @mcp.tool() + def return_track_get_info(return_index: int) -> dict: + """Get all properties of a return track. Returns {name, mute, solo, color, volume, panning}.""" + c = get_client() + return { + "name": c.query("/live/return_track/get/name", return_index)[0], + "mute": bool(c.query("/live/return_track/get/mute", return_index)[0]), + "solo": bool(c.query("/live/return_track/get/solo", return_index)[0]), + "color": c.query("/live/return_track/get/color", return_index)[0], + "volume": c.query("/live/return_track/get/volume", return_index)[0], + "panning": c.query("/live/return_track/get/panning", return_index)[0], + } + + @mcp.tool() + def return_track_set_volume(return_index: int, volume: float) -> None: + """Set return track volume (0.0 = -inf, 0.85 ≈ 0 dB, 1.0 = +6 dB).""" + get_client().cmd("/live/return_track/set/volume", return_index, volume) + + @mcp.tool() + def return_track_set_panning(return_index: int, panning: float) -> None: + """Set return track panning (-1.0 = full left, 0.0 = center, 1.0 = full right).""" + get_client().cmd("/live/return_track/set/panning", return_index, panning) + + @mcp.tool() + def return_track_set_mute(return_index: int, muted: bool) -> None: + """Mute or unmute a return track.""" + get_client().cmd("/live/return_track/set/mute", return_index, int(muted)) + + @mcp.tool() + def return_track_set_solo(return_index: int, solo: bool) -> None: + """Solo or unsolo a return track.""" + get_client().cmd("/live/return_track/set/solo", return_index, int(solo)) + + @mcp.tool() + def return_track_set_name(return_index: int, name: str) -> None: + """Rename a return track.""" + get_client().cmd("/live/return_track/set/name", return_index, name) + + @mcp.tool() + def return_track_set_color(return_index: int, color_index: int) -> None: + """Set return track color by palette index (0–69).""" + get_client().cmd("/live/return_track/set/color_index", return_index, color_index) + + @mcp.tool() + def return_track_get_send(return_index: int, send_index: int) -> float: + """Get the send level from a return track to another return track.""" + return get_client().query("/live/return_track/get/send", return_index, send_index)[0] + + @mcp.tool() + def return_track_set_send(return_index: int, send_index: int, value: float) -> None: + """Set the send level from a return track to another return track (0.0–1.0).""" + get_client().cmd("/live/return_track/set/send", return_index, send_index, value) + + @mcp.tool() + def return_track_get_meter(return_index: int) -> dict: + """Get return track output meter levels. Returns {level, left, right}.""" + c = get_client() + return { + "level": c.query("/live/return_track/get/output_meter_level", return_index)[0], + "left": c.query("/live/return_track/get/output_meter_left", return_index)[0], + "right": c.query("/live/return_track/get/output_meter_right", return_index)[0], + } + + @mcp.tool() + def return_track_get_devices(return_index: int) -> list: + """Get all devices on a return track. Returns list of {index, name}.""" + names = get_client().query("/live/return_track/get/devices/name", return_index) + return [{"index": i, "name": n} for i, n in enumerate(names)] diff --git a/src/ableton_mcp/tools/scene.py b/src/ableton_mcp/tools/scene.py index a742b13..cd0ee81 100644 --- a/src/ableton_mcp/tools/scene.py +++ b/src/ableton_mcp/tools/scene.py @@ -11,9 +11,9 @@ def register(mcp): get_client().cmd("/live/scene/fire", scene_index) @mcp.tool() - def scene_fire_selected() -> None: - """Launch the currently selected scene.""" - get_client().cmd("/live/scene/fire_selected") + def scene_fire_as_selected(scene_index: int) -> None: + """Fire a scene as if it were selected (respects follow actions).""" + get_client().cmd("/live/scene/fire_as_selected", scene_index) @mcp.tool() def scene_get_info(scene_index: int) -> dict: @@ -31,6 +31,17 @@ def register(mcp): "is_triggered": bool(c.query("/live/scene/get/is_triggered", scene_index)[0]), } + @mcp.tool() + def scene_get_num_clips(scene_index: int) -> int: + """Get the number of clips in a scene.""" + return get_client().query("/live/scene/get/num_clips", scene_index)[0] + + @mcp.tool() + def scene_get_clip_slots(scene_index: int) -> list: + """Get clip slot occupancy for a scene. Returns list of {slot_index, has_clip}.""" + result = get_client().query("/live/scene/get/clip_slots/has_clip", scene_index) + return [{"slot_index": i, "has_clip": bool(v)} for i, v in enumerate(result)] + @mcp.tool() def scene_set_name(scene_index: int, name: str) -> None: """Rename a scene.""" diff --git a/src/ableton_mcp/tools/song.py b/src/ableton_mcp/tools/song.py index 00635ee..1c8e106 100644 --- a/src/ableton_mcp/tools/song.py +++ b/src/ableton_mcp/tools/song.py @@ -29,10 +29,8 @@ def register(mcp): "can_redo": bool(c.query("/live/song/get/can_redo")[0]), "num_tracks": c.query("/live/song/get/num_tracks")[0], "num_scenes": c.query("/live/song/get/num_scenes")[0], + "num_return_tracks": c.query("/live/song/get/num_return_tracks")[0], "groove_amount": c.query("/live/song/get/groove_amount")[0], - "is_ableton_link_enabled": bool(c.query("/live/song/get/is_ableton_link_enabled")[0]), - "root_note": c.query("/live/song/get/root_note")[0], - "scale_name": c.query("/live/song/get/scale_name")[0], } @mcp.tool() @@ -107,6 +105,21 @@ def register(mcp): """Jump the playhead to a position in beats.""" get_client().cmd("/live/song/set/current_song_time", beats) + @mcp.tool() + def song_jump_by(beats: float) -> None: + """Move the playhead forward or backward by a number of beats (negative = backward).""" + get_client().cmd("/live/song/jump_by", beats) + + @mcp.tool() + def song_jump_to_next_cue() -> None: + """Jump the playhead to the next cue point.""" + get_client().cmd("/live/song/jump_to_next_cue") + + @mcp.tool() + def song_jump_to_prev_cue() -> None: + """Jump the playhead to the previous cue point.""" + get_client().cmd("/live/song/jump_to_prev_cue") + @mcp.tool() def song_get_time_signature() -> dict: """Get the global time signature. Returns {numerator, denominator}.""" @@ -194,18 +207,6 @@ def register(mcp): """Enable or disable punch-out recording.""" get_client().cmd("/live/song/set/punch_out", int(enabled)) - # --- Scale / Root Note --- - - @mcp.tool() - def song_set_root_note(note: int) -> None: - """Set the root note (0=C, 1=C#, ..., 11=B).""" - get_client().cmd("/live/song/set/root_note", note) - - @mcp.tool() - def song_set_scale_name(scale_name: str) -> None: - """Set the scale name, e.g. 'Major', 'Minor', 'Dorian'.""" - get_client().cmd("/live/song/set/scale_name", scale_name) - # --- Tracks --- @mcp.tool() @@ -214,6 +215,16 @@ def register(mcp): names = get_client().query("/live/song/get/track_names") return [{"index": i, "name": n} for i, n in enumerate(names)] + @mcp.tool() + def song_get_return_tracks() -> list: + """Get all return track names. Returns list of {index, name}.""" + c = get_client() + count = c.query("/live/song/get/num_return_tracks")[0] + if count == 0: + return [] + names = c.query("/live/song/get/return_track_names") + return [{"index": i, "name": n} for i, n in enumerate(names)] + @mcp.tool() def song_create_audio_track(index: int = -1) -> None: """Create a new audio track at the given index (-1 = end).""" @@ -249,7 +260,7 @@ def register(mcp): @mcp.tool() def song_get_scenes() -> list: """Get all scene names. Returns list of {index, name}.""" - names = get_client().query("/live/song/get/scenes/name") + names = get_client().query("/live/song/get/scene_names") return [{"index": i, "name": n} for i, n in enumerate(names)] @mcp.tool() @@ -267,48 +278,13 @@ def register(mcp): """Duplicate the scene at scene_index.""" get_client().cmd("/live/song/duplicate_scene", scene_index) - @mcp.tool() - def song_capture_and_insert_scene() -> None: - """Capture the current session state and insert it as a new scene.""" - get_client().cmd("/live/song/capture_and_insert_scene") - # --- Cue Points --- @mcp.tool() def song_get_cue_points() -> list: - """Get all cue point names. Returns list of {index, name}.""" - names = get_client().query("/live/song/get/cue_points") - return [{"index": i, "name": n} for i, n in enumerate(names)] - - @mcp.tool() - def song_jump_to_cue(index_or_name: str) -> None: - """Jump to a cue point by index (int as string) or name.""" - try: - val = int(index_or_name) - except ValueError: - val = index_or_name - get_client().cmd("/live/song/cue_point/jump", val) - - @mcp.tool() - def song_add_or_delete_cue() -> None: - """Toggle a cue point at the current playhead position.""" - get_client().cmd("/live/song/set_or_delete_cue") - - @mcp.tool() - def song_set_cue_name(cue_index: int, name: str) -> None: - """Set the name of a cue point by index.""" - get_client().cmd("/live/song/cue_point/set/name", cue_index, name) - - # --- Structure export --- - - @mcp.tool() - def song_export_structure() -> None: - """Export the full session structure to a JSON file in the temp directory.""" - get_client().cmd("/live/song/export/structure") - - # --- Link --- - - @mcp.tool() - def song_set_ableton_link(enabled: bool) -> None: - """Enable or disable Ableton Link.""" - get_client().cmd("/live/song/set/is_ableton_link_enabled", int(enabled)) + """Get all cue points. Returns list of {index, name, time}.""" + result = get_client().query("/live/song/get/cue_points") + cues = [] + for i in range(0, len(result), 2): + cues.append({"index": i // 2, "name": result[i], "time": result[i + 1]}) + return cues diff --git a/src/ableton_mcp/tools/system.py b/src/ableton_mcp/tools/system.py index 4d1a34a..b60e041 100644 --- a/src/ableton_mcp/tools/system.py +++ b/src/ableton_mcp/tools/system.py @@ -30,17 +30,6 @@ def register(mcp): """Display a message in the Ableton Live status bar.""" get_client().cmd("/live/api/show_message", message) - @mcp.tool() - def system_set_log_level(level: str) -> None: - """Set AbletonOSC log level. level: debug | info | warning | error | critical.""" - get_client().cmd("/live/api/set/log_level", level) - - @mcp.tool() - def system_get_log_level() -> str: - """Get the current AbletonOSC log level.""" - result = get_client().query("/live/api/get/log_level") - return result[0] - @mcp.tool() def system_reload_api() -> None: """Reload all AbletonOSC modules (useful after script updates).""" diff --git a/src/ableton_mcp/tools/track.py b/src/ableton_mcp/tools/track.py index 3a61173..69292bf 100644 --- a/src/ableton_mcp/tools/track.py +++ b/src/ableton_mcp/tools/track.py @@ -93,38 +93,54 @@ def register(mcp): @mcp.tool() def track_get_clips(track_index: int) -> list: - """Get all clips on a track. Returns list of {index, name, length, color}.""" + """Get all session clips on a track. Returns list of {index, name, length, color, is_playing}.""" c = get_client() names = c.query("/live/track/get/clips/name", track_index) lengths = c.query("/live/track/get/clips/length", track_index) colors = c.query("/live/track/get/clips/color", track_index) + playing = c.query("/live/track/get/clips/is_playing", track_index) result = [] - for i, (name, length, color) in enumerate(zip(names, lengths, colors)): + for i, (name, length, color, is_play) in enumerate(zip(names, lengths, colors, playing)): if name: - result.append({"index": i, "name": name, "length": length, "color": color}) + result.append({ + "index": i, + "name": name, + "length": length, + "color": color, + "is_playing": bool(is_play), + }) return result @mcp.tool() def track_get_devices(track_index: int) -> list: - """Get all devices on a track. Returns list of {index, name, type, class_name}.""" + """Get all devices on a track. Returns list of {index, name, type, class_name, can_have_chains}.""" c = get_client() names = c.query("/live/track/get/devices/name", track_index) types = c.query("/live/track/get/devices/type", track_index) class_names = c.query("/live/track/get/devices/class_name", track_index) + can_chain = c.query("/live/track/get/devices/can_have_chains", track_index) type_map = {1: "audio_effect", 2: "instrument", 4: "midi_effect"} return [ - {"index": i, "name": n, "type": type_map.get(t, t), "class_name": cn} - for i, (n, t, cn) in enumerate(zip(names, types, class_names)) + { + "index": i, + "name": n, + "type": type_map.get(t, t), + "class_name": cn, + "can_have_chains": bool(ch), + } + for i, (n, t, cn, ch) in enumerate(zip(names, types, class_names, can_chain)) ] @mcp.tool() def track_get_meter(track_index: int) -> dict: - """Get track output meter levels. Returns {level, left, right}.""" + """Get track output meter levels. Returns {level, left, right, peak_left, peak_right}.""" c = get_client() return { "level": c.query("/live/track/get/output_meter_level", track_index)[0], "left": c.query("/live/track/get/output_meter_left", track_index)[0], "right": c.query("/live/track/get/output_meter_right", track_index)[0], + "peak_left": c.query("/live/track/get/output_meter_peak_left", track_index)[0], + "peak_right": c.query("/live/track/get/output_meter_peak_right", track_index)[0], } @mcp.tool() @@ -145,17 +161,52 @@ def register(mcp): get_client().cmd("/live/track/delete_device", track_index, device_index) @mcp.tool() - def track_get_available_input_routing_types(track_index: int) -> list: - """Get available input routing types for a track.""" - result = get_client().query("/live/track/get/available_input_routing_types", track_index) - return list(result) + def track_duplicate_device(track_index: int, device_index: int) -> None: + """Duplicate a device on a track.""" + get_client().cmd("/live/track/duplicate_device", track_index, device_index) + + # --- Input Routing --- @mcp.tool() - def track_set_input_routing_type(track_index: int, routing_type: int) -> None: - """Set the input routing type for a track.""" + def track_get_input_routing(track_index: int) -> dict: + """Get the current input routing type and channel for a track.""" + c = get_client() + return { + "type": c.query("/live/track/get/input_routing_type", track_index)[0], + "channel": c.query("/live/track/get/input_routing_channel", track_index)[0], + "available_types": list(c.query("/live/track/get/available_input_routing_types", track_index)), + "available_channels": list(c.query("/live/track/get/available_input_routing_channels", track_index)), + } + + @mcp.tool() + def track_set_input_routing_type(track_index: int, routing_type: str) -> None: + """Set the input routing type for a track (use a name from available_types).""" get_client().cmd("/live/track/set/input_routing_type", track_index, routing_type) @mcp.tool() - def track_set_output_routing_type(track_index: int, routing_type: int) -> None: - """Set the output routing type for a track.""" + def track_set_input_routing_channel(track_index: int, channel: str) -> None: + """Set the input routing channel for a track (use a name from available_channels).""" + get_client().cmd("/live/track/set/input_routing_channel", track_index, channel) + + # --- Output Routing --- + + @mcp.tool() + def track_get_output_routing(track_index: int) -> dict: + """Get the current output routing type and channel for a track.""" + c = get_client() + return { + "type": c.query("/live/track/get/output_routing_type", track_index)[0], + "channel": c.query("/live/track/get/output_routing_channel", track_index)[0], + "available_types": list(c.query("/live/track/get/available_output_routing_types", track_index)), + "available_channels": list(c.query("/live/track/get/available_output_routing_channels", track_index)), + } + + @mcp.tool() + def track_set_output_routing_type(track_index: int, routing_type: str) -> None: + """Set the output routing type for a track (use a name from available_types).""" get_client().cmd("/live/track/set/output_routing_type", track_index, routing_type) + + @mcp.tool() + def track_set_output_routing_channel(track_index: int, channel: str) -> None: + """Set the output routing channel for a track (use a name from available_channels).""" + get_client().cmd("/live/track/set/output_routing_channel", track_index, channel) diff --git a/src/ableton_mcp/tools/view.py b/src/ableton_mcp/tools/view.py index 7b5d9f7..d07a02d 100644 --- a/src/ableton_mcp/tools/view.py +++ b/src/ableton_mcp/tools/view.py @@ -8,7 +8,8 @@ def register(mcp): @mcp.tool() def view_get_selection() -> dict: """Get the currently selected track, scene, clip, and device. - Returns {selected_track, selected_scene, selected_clip_track, selected_clip_index, selected_device_track, selected_device_index}.""" + Returns {selected_track, selected_scene, selected_clip_track, selected_clip_index, + selected_device_track, selected_device_index}.""" c = get_client() scene = c.query("/live/view/get/selected_scene")[0] track = c.query("/live/view/get/selected_track")[0] @@ -23,6 +24,11 @@ def register(mcp): "selected_device_index": device[1] if len(device) >= 2 else None, } + @mcp.tool() + def view_get_focused_document_view() -> str: + """Get the name of the currently focused document view (e.g. 'Session', 'Arranger').""" + return get_client().query("/live/view/get/focused_document_view")[0] + @mcp.tool() def view_set_selected_track(track_index: int) -> None: """Select a track in the Ableton UI.""" @@ -39,6 +45,16 @@ def register(mcp): get_client().cmd("/live/view/set/selected_clip", track_index, clip_index) @mcp.tool() - def view_set_selected_device(track_index: int, device_index: int) -> None: - """Select a device in the Ableton UI.""" - get_client().cmd("/live/view/set/selected_device", track_index, device_index) + def view_show_clip_detail() -> None: + """Show the clip detail view (bottom panel) in Ableton.""" + get_client().cmd("/live/view/show_clip_detail_view") + + @mcp.tool() + def view_show_device_detail() -> None: + """Show the device detail view (bottom panel) in Ableton.""" + get_client().cmd("/live/view/show_device_detail_view") + + @mcp.tool() + def view_focus_browser() -> None: + """Focus the Ableton browser panel.""" + get_client().cmd("/live/view/focus_browser")