Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ cython_debug/

*.ckpt
*.wav
*.asd
wandb/*
*.out

Expand Down
149 changes: 149 additions & 0 deletions optimized/mlx/ableton/AudioInserter/AudioInserter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import Live
import socket
import threading
import os
import json
import re

from _Framework.ControlSurface import ControlSurface

SOCKET_HOST = "127.0.0.1"
SOCKET_PORT = 9129
BUFFER_SIZE = 4096


class AudioInserter(ControlSurface):

def __init__(self, c_instance):
super().__init__(c_instance)
self._c_instance = c_instance
self._server_thread = None
self._running = False
self._start_server()
self.log_message("AudioInserter: started, listening on port %d" % SOCKET_PORT)

def _start_server(self):
self._running = True
self._server_thread = threading.Thread(target=self._server_loop, daemon=True)
self._server_thread.start()

def _server_loop(self):
try:
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((SOCKET_HOST, SOCKET_PORT))
srv.listen(5)
srv.settimeout(1.0)
while self._running:
try:
conn, _ = srv.accept()
data = conn.recv(BUFFER_SIZE).decode("utf-8").strip()
response = self._handle_command(data)
conn.sendall((response + "\n").encode("utf-8"))
conn.close()
Comment on lines +37 to +43
Comment on lines +39 to +43
except socket.timeout:
continue
except Exception as e:
self.log_message("AudioInserter socket error: %s" % str(e))
except Exception as e:
self.log_message("AudioInserter server error: %s" % str(e))
Comment on lines +30 to +49

def _handle_command(self, raw):
try:
cmd = json.loads(raw)
except Exception:
return json.dumps({"ok": False, "error": "Invalid JSON"})

action = cmd.get("action", "")

if action == "ping":
return json.dumps({"ok": True, "message": "pong"})

if action == "insert_audio":
file_path = cmd.get("file_path", "")
if not file_path:
return json.dumps({"ok": False, "error": "No file_path provided"})
if not os.path.isfile(file_path):
return json.dumps({"ok": False, "error": "File not found: %s" % file_path})

result = {"done": False, "ok": False, "error": None}
event = threading.Event()
track_name = cmd.get("track_name", "")

def do_insert():
try:
result["ok"], result["error"] = self._insert_audio(file_path, track_name)
except Exception as e:
result["ok"] = False
result["error"] = str(e)
finally:
result["done"] = True
event.set()

self.schedule_message(1, do_insert)
event.wait(timeout=10.0)

if result["ok"]:
msg = result.get("error") or ("Inserted: %s" % os.path.basename(file_path))
return json.dumps({"ok": True, "message": msg})
else:
return json.dumps({"ok": False, "error": result.get("error", "Unknown error")})
Comment on lines +84 to +90
Comment on lines +84 to +90

return json.dumps({"ok": False, "error": "Unknown action: %s" % action})

def _clean_track_name(self, name):
name = re.sub(r'\b\w+\s*:\s*\S+', '', name)
name = re.sub(r'\s{2,}', ' ', name).strip()
return name if name else "Audio"

def _insert_audio(self, file_path, track_name=""):
import shutil, time
song = self._c_instance.song()
insert_time = song.current_song_time

song.create_audio_track(-1)
track = song.tracks[-1]

raw_name = track_name if track_name else os.path.splitext(os.path.basename(file_path))[0]
clean_name = self._clean_track_name(raw_name)
track.name = clean_name

# Copy file to a unique path so each generation is preserved independently
ext = os.path.splitext(file_path)[1]
unique_name = "%s_%s%s" % (clean_name.replace(" ", "_"), int(time.time()), ext)
dest_dir = os.path.dirname(file_path)
unique_path = os.path.join(dest_dir, unique_name)
shutil.copy2(file_path, unique_path)
Comment on lines +111 to +116
self.log_message("AudioInserter: copied to %s" % unique_path)

# Step 1: create audio clip in session slot 0
slot = track.clip_slots[0]
slot.create_audio_clip(unique_path)
clip = slot.clip

if clip is None:
return False, "clip is None after create_audio_clip"

clip.name = clean_name

self.log_message("AudioInserter: session clip created, length=%s is_session_clip=%s" % (
clip.length, clip.is_session_clip))

# Step 2: duplicate to arrangement at playhead using track.duplicate_clip_to_arrangement
# Signature from Live 11 sources: duplicate_clip_to_arrangement(clip, position)
try:
track.duplicate_clip_to_arrangement(clip, insert_time)
self.log_message("AudioInserter: duplicate_clip_to_arrangement succeeded")

# Step 3: delete the session clip — we only want it in arrangement
slot.delete_clip()

return True, None
except Exception as e:
self.log_message("AudioInserter: duplicate_clip_to_arrangement failed: %s" % str(e))
return False, "duplicate_clip_to_arrangement failed: %s" % str(e)

def disconnect(self):
self._running = False
super().disconnect()
self.log_message("AudioInserter: disconnected")
4 changes: 4 additions & 0 deletions optimized/mlx/ableton/AudioInserter/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .AudioInserter import AudioInserter

def create_instance(c_instance):
return AudioInserter(c_instance)
66 changes: 66 additions & 0 deletions optimized/mlx/ableton/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Ableton Live integration (Experimental!)

Insert `sa3` generations directly into Ableton Live at the current playhead position.

Two pieces work together:

| File | Where it runs |
|------|--------------|
| `AudioInserter/` | Inside Ableton — a MIDI Remote Script that listens on a local socket |
| `insert_audio.py` | On the command line — sends files to that socket |

## 1. Install the AudioInserter Remote Script

There are two places Ableton looks for Remote Scripts on Mac:

| Location | Notes |
|----------|-------|
| `~/Music/Ableton/User Library/Remote Scripts/` | **Recommended** — persists across Ableton updates |
| `/Applications/Ableton Live *.app/Contents/App-Resources/MIDI Remote Scripts/` | Works, but gets wiped when you update Ableton |

Install into the User Library (create the folder if it doesn't exist yet):

```bash
mkdir -p ~/Music/Ableton/User\ Library/Remote\ Scripts
cp -r AudioInserter ~/Music/Ableton/User\ Library/Remote\ Scripts/AudioInserter
```

Then in Ableton: **Preferences → MIDI → Control Surfaces** — add a new surface and choose **AudioInserter**. No MIDI port needed.

Verify it's running:

```bash
python3 insert_audio.py --ping
# ✓ Ableton is connected and ready
```

## 2. Insert audio

```bash
# Insert a specific file at the current playhead
python3 insert_audio.py /path/to/out.wav

# Insert whatever wav was most recently dropped on your Desktop
python3 insert_audio.py

# Watch a folder and auto-insert each new file as it appears
python3 insert_audio.py --watch ~/Desktop
```
Comment on lines +39 to +48

The track is named after the file by default. If the env var `_SA3_LAST_PROMPT` is set, that prompt text is used as the track name instead — handy if you wrap `sa3` in a shell alias.

## 3. Typical workflow with sa3

```bash
# Generate
./sa3 --prompt "driving techno loop" --dit medium --decoder same-l --out out.wav

# Insert
python3 ableton/insert_audio.py out.wav
```
Comment on lines +54 to +60

Or use watch mode: start it once and every `out.wav` you generate lands in Ableton automatically:

```bash
python3 ableton/insert_audio.py --watch /path/to/sa3/output/dir
```
Loading
Loading