Python Remote Script¶
The VimAbl Python Remote Script runs inside Ableton Live and provides the bridge between Live's internal state and external integrations (Hammerspoon, WebSocket, UDP/OSC).
Architecture Overview¶
┌─────────────────────────────────────────────────┐
│ Ableton Live Process │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ LiveState (ControlSurface) │ │
│ │ • Main event loop (~60Hz) │ │
│ │ • Logging infrastructure │ │
│ │ • Component lifecycle management │ │
│ └──────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌─────────┐ ┌──────────────┐ │
│ │ Observers │ │ UDP │ │ Command │ │
│ │ Manager │ │ Sender │ │ Server │ │
│ └───────────┘ └─────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
(UDP/OSC) (UDP/OSC Events) (TCP Socket)
Logging System¶
Architecture¶
VimAbl uses a thread-safe, queue-based logging system optimized for Ableton Live's constraints:
- Zero Live API calls from background threads - Only the main thread calls
ControlSurface.log_message() - Tuple-based queueing - Formatting deferred to main thread for speed
- Adaptive queue draining - Automatically increases drain rate when backlog builds
- Severity level filtering - DEBUG, INFO, WARN, ERROR, CRITICAL
- Performance metrics - Real-time tracking with <0.1% overhead
Key Components¶
logging_config.py¶
Core logging infrastructure:
# Producer side (any thread)
log(component: str, message: str, level: str = "INFO", force: bool = False)
# Consumer side (main thread only)
init_logging(log_callback) # Initialize on startup
drain_log_queue(max_messages) # Drain in update_display()
clear_log_queue() # Clear on shutdown
# Performance monitoring
get_log_stats() -> dict # Get metrics
reset_log_stats() # Reset counters
Configuration¶
# Global settings (logging_config.py)
ENABLE_LOGGING = True # Toggle all logging
ENABLED_LEVELS = {"INFO", "WARN", "ERROR", "CRITICAL"}
Thread Safety¶
The logging system handles cross-thread communication safely:
- Background threads (UDP, observers) → Call
log()→ Messages queued - Main thread (60Hz) → Calls
drain_log_queue()→ Messages written viaControlSurface.log_message() - No locks required - Python's
Queueis thread-safe
Performance Metrics¶
Automatically tracked with negligible overhead:
| Metric | Type | Description |
|---|---|---|
messages_enqueued |
Counter | Total messages sent to queue |
messages_drained |
Counter | Messages successfully logged |
messages_dropped |
Counter | Messages lost (queue full) |
peak_queue_size |
Gauge | Highest queue depth |
queue_utilization |
Percentage | Current queue capacity used |
drop_rate |
Percentage | Message loss rate |
See Performance Tuning for details.
Observer System¶
ObserverManager¶
Manages all Live API observers:
- TrackObserver - Per-track state (name, mute, arm, volume, devices, clip slots)
- DeviceObserver - Per-device parameters (debounced)
- TransportObserver - Global playback state (play, tempo)
- SceneObserver - Per-scene state (name, color, triggered)
- SessionCursorObserver - Session View cursor tracking
Event Flow¶
UDP/OSC Event System¶
UDPSender¶
Non-blocking UDP sender for real-time events:
- Fire-and-forget - Never blocks main thread
- Sequenced messages - Monotonic sequence numbers
- Batching support - Group related events
- Error resilient - Logs failures but continues
See OSC Protocol for event format.
Command Server¶
TCP socket server for bidirectional communication:
- Port 9001 - Accepts commands from Hammerspoon
- Async I/O - Non-blocking socket operations
- JSON protocol - Structured command/response format
See Commands API for available commands.
Lifecycle¶
# Startup (Ableton loads script)
LiveState.__init__()
├─ init_logging(self.log_message)
├─ UDPSender.start()
├─ ObserverManager.start()
├─ SessionCursorObserver.init()
├─ CommandServer.start()
└─ _setup_tasks() # Schedule recurring tasks
# Task Scheduling (Phase 5j Optimization)
_setup_tasks()
├─ Task 1: _poll_observer_manager() # Every frame (~60Hz)
├─ Task 2: _poll_cursor_observer() # Every frame (~60Hz)
└─ Task 3: _log_stats_periodically() # Every 5 minutes (18000 frames)
# Main loop (~60Hz) - OPTIMIZED
LiveState.update_display()
├─ drain_log_queue() # Process logs (micro-optimized)
└─ super().update_display() # Run Task scheduler
├─ _poll_observer_manager() # Task 1: Debounce checks
├─ _poll_cursor_observer() # Task 2: Cursor polling
└─ _log_stats_periodically() # Task 3: Stats (every 5 min)
# Shutdown (Ableton unloads script)
LiveState.disconnect()
├─ SessionCursorObserver.disconnect()
├─ ObserverManager.stop()
├─ UDPSender.stop()
├─ CommandServer.stop()
├─ get_log_stats() → Log final metrics
└─ drain_log_queue(max_messages=1000) → Flush remaining logs
Task-Based Architecture (Phase 5j)¶
VimAbl uses _Framework.Task to delegate polling logic from update_display(), keeping it minimal and performant.
Benefits:
- Cleaner separation of concerns - Polling logic moved to dedicated task functions
- Better testability - Each task can be tested independently
- Improved readability -
update_display()reduced from ~40 lines to 7 lines - Graceful fallback - Falls back to legacy polling if Task setup fails
Task Callbacks:
def _poll_observer_manager(self, delta):
"""Task callback: Check for trailing edge debounce events."""
if hasattr(self, 'udp_observer_manager'):
self.udp_observer_manager.update()
return RUNNING # Keep task alive
def _poll_cursor_observer(self, delta):
"""Task callback: Update cursor observer."""
if hasattr(self, 'cursor_observer'):
self.cursor_observer.update()
return RUNNING # Keep task alive
def _log_stats_periodically(self, delta):
"""Task callback: Log performance stats every 5 minutes."""
self._stats_tick_counter += 1
if self._stats_tick_counter >= 18000: # 60Hz * 60s * 5min
stats = get_log_stats()
log("LiveState", f"Logging stats: {stats['messages_drained']} drained...", level="INFO")
self._stats_tick_counter = 0
return RUNNING # Keep task alive
Optimized update_display():
def update_display(self):
"""
The fastest possible update loop for a heavy Ableton Remote Script.
Responsibilities (only):
- Drain logger queue (cheap, micro-optimized)
- Let _Framework.Task run its scheduled tasks
- DO NOT do any logic, debounce, polling, or model building here
"""
# 1. Drain any queued log entries
try:
drain_log_queue()
except Exception:
pass # Logging must never break the display loop
# 2. Run Ableton's task system (executes our scheduled tasks above)
super(LiveState, self).update_display()
# 3. Fallback: If task setup failed, use old polling method
if hasattr(self, '_task_setup_failed') and self._task_setup_failed:
try:
if hasattr(self, 'udp_observer_manager'):
self.udp_observer_manager.update()
if hasattr(self, 'cursor_observer'):
self.cursor_observer.update()
except Exception:
pass
File Structure¶
src/remote_script/
├── __init__.py # Package initialization
├── LiveState.py # Main ControlSurface class
├── logging_config.py # Centralized logging system
├── observers.py # Observer implementations
├── cursor_observer.py # Session View cursor tracking
├── udp_sender.py # UDP/OSC event sender
├── osc.py # OSC message encoding
├── commands.py # Command handlers
├── server.py # TCP command server
├── debounce.py # Event debouncing
└── _Framework/ # Ableton's Remote Script framework
Best Practices¶
Logging¶
# ✅ Good - Explicit severity levels
log("Component", "Started successfully", level="INFO")
log("Component", "Invalid state detected", level="WARN")
log("Component", "Critical failure", level="ERROR", force=True)
# ❌ Bad - Logging in hot paths without level filtering
for i in range(1000):
log("HotPath", f"Processing {i}") # Spams logs!
# ✅ Good - Use appropriate levels and force flag
log("Init", "System starting", level="INFO") # Normal operation
log("Debug", "Value: {x}", level="DEBUG") # Filtered out by default
log("Critical", "System crash", level="CRITICAL", force=True) # Always logged
Observer Callbacks¶
# ✅ Good - Non-blocking, just set flags
def _on_name_changed(self):
self._name_changed = True
# ❌ Bad - Heavy work in callback
def _on_name_changed(self):
self.log("Long message...") # String formatting
self.send_event(...) # Network I/O
self.update_database(...) # Database write
Error Handling¶
# ✅ Good - Graceful degradation
try:
risky_operation()
except Exception as e:
log("Component", f"Operation failed: {e}", level="ERROR")
# Continue with degraded functionality
# ❌ Bad - Crash on error
risky_operation() # Uncaught exception crashes script
Debugging¶
Viewing Logs¶
Ableton Live logs to:
- macOS: ~/Library/Preferences/Ableton/Live {version}/Log.txt
- Windows: %APPDATA%\Ableton\Live {version}\Preferences\Log.txt
Live Reload¶
After editing Remote Script code: 1. Go to Live Preferences → Link/Tempo/MIDI 2. Change Control Surface to "None" 3. Change back to "VimAbl" 4. Script reloads without restarting Ableton
Common Issues¶
| Issue | Cause | Solution |
|---|---|---|
| No logs appearing | ENABLE_LOGGING = False |
Set to True in logging_config.py |
| High drop rate | Excessive logging | Reduce ENABLED_LEVELS, disable DEBUG |
| Observer not firing | Listener not registered | Check _add_listeners() called |
| UDP events not sent | Sender not started | Check UDPSender.start() called |
See Troubleshooting for more details.