osc_server.py•12.2 kB
#!/usr/bin/env python3
"""
OSC-based MCP Server for REAPER
This module provides an MCP server implementation using python-osc to communicate with REAPER
"""
import json
import sys
import os
import time
import traceback
import logging
from pythonosc import udp_client
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import BlockingOSCUDPServer
from mcp.server.fastmcp import FastMCP
import threading
# Set up logging
logger = logging.getLogger("reaper_mcp.osc_server")
# REAPER Action IDs
ACTION_NEW_PROJECT = 40023
ACTION_INSERT_TRACK = 40001
ACTION_SELECT_TRACK_1 = 40939
ACTION_SELECT_TRACK_2 = 40940
ACTION_SELECT_TRACK_3 = 40941
ACTION_SELECT_TRACK_4 = 40942
ACTION_SELECT_TRACK_5 = 40943
ACTION_SELECT_TRACK_6 = 40944
ACTION_SELECT_TRACK_7 = 40945
ACTION_SELECT_TRACK_8 = 40946
ACTION_INSERT_MIDI_ITEM = 40214
ACTION_SET_TEMPO = 40364
ACTION_SAVE_PROJECT = 40022
class ReaperOSCServer:
    """
    REAPER OSC Server implementation for MCP
    """
    
    def __init__(self, host="127.0.0.1", send_port=8000, receive_port=9000):
        """
        Initialize the REAPER OSC Server
        
        Args:
            host (str): The IP address of the REAPER instance
            send_port (int): The port REAPER listens on for OSC messages
            receive_port (int): The port we listen on for REAPER responses
        """
        self.host = host
        self.send_port = send_port
        self.receive_port = receive_port
        
        # Create the MCP server
        self.mcp = FastMCP("ReaperOSCMCP")
        
        # Create OSC client for sending commands to REAPER
        self.client = udp_client.SimpleUDPClient(self.host, self.send_port)
        
        # Global variables to store project state
        self.current_project = {
            "name": "Untitled",
            "path": "",
            "tracks": []
        }
        
        # Set up OSC dispatcher
        self.dispatcher = Dispatcher()
        self.dispatcher.map("/track/*/name", self.handle_track_info)
        self.dispatcher.map("/project/name", self.handle_project_info)
        self.dispatcher.map("/project/path", self.handle_project_info)
        
        # Start OSC server in a separate thread
        self.osc_thread = threading.Thread(target=self.start_osc_server, daemon=True)
        self.osc_thread.start()
        
        # Register MCP tools
        self.register_mcp_tools()
        
        # Request initial project info
        self.request_project_info()
    
    def start_osc_server(self):
        """Start the OSC server to listen for REAPER responses"""
        server = BlockingOSCUDPServer((self.host, self.receive_port), self.dispatcher)
        logger.info(f"OSC server listening on {self.host}:{self.receive_port}")
        server.serve_forever()
    
    def handle_track_info(self, address, *args):
        """Handle track info messages from REAPER"""
        logger.debug(f"Received track info: {address} {args}")
        # Update our track info cache
        track_idx = int(address.split('/')[-1])
        if track_idx >= len(self.current_project["tracks"]):
            self.current_project["tracks"].extend([{} for _ in range(track_idx - len(self.current_project["tracks"]) + 1)])
        self.current_project["tracks"][track_idx]["name"] = args[0] if args else f"Track {track_idx+1}"
    
    def handle_project_info(self, address, *args):
        """Handle project info messages from REAPER"""
        logger.debug(f"Received project info: {address} {args}")
        if address == "/project/name" and args:
            self.current_project["name"] = args[0]
        elif address == "/project/path" and args:
            self.current_project["path"] = args[0]
    
    def request_project_info(self):
        """Request project information from REAPER"""
        self.client.send_message("/project/name/get", None)
        self.client.send_message("/project/path/get", None)
        self.client.send_message("/track/count/get", None)
        time.sleep(0.1)  # Give REAPER time to respond
    
    def refresh_track_list(self):
        """Refresh the track list from REAPER"""
        self.client.send_message("/track/count/get", None)
        time.sleep(0.1)  # Wait for response
        
        # Request info for each track
        for i in range(len(self.current_project["tracks"])):
            self.client.send_message(f"/track/{i}/name/get", None)
        time.sleep(0.1)  # Wait for responses
    
    def select_track(self, track_index):
        """Select a track by index using action IDs"""
        if track_index == 0:
            self.client.send_message("/action", [ACTION_SELECT_TRACK_1])
        elif track_index == 1:
            self.client.send_message("/action", [ACTION_SELECT_TRACK_2])
        elif track_index == 2:
            self.client.send_message("/action", [ACTION_SELECT_TRACK_3])
        elif track_index == 3:
            self.client.send_message("/action", [ACTION_SELECT_TRACK_4])
        elif track_index == 4:
            self.client.send_message("/action", [ACTION_SELECT_TRACK_5])
        elif track_index == 5:
            self.client.send_message("/action", [ACTION_SELECT_TRACK_6])
        elif track_index == 6:
            self.client.send_message("/action", [ACTION_SELECT_TRACK_7])
        elif track_index == 7:
            self.client.send_message("/action", [ACTION_SELECT_TRACK_8])
        else:
            # For tracks beyond 8, we'll use the OSC method (less reliable)
            self.client.send_message(f"/track/{track_index}/select", [1])
        time.sleep(0.2)  # Wait for REAPER to process
    
    def register_mcp_tools(self):
        """Register all MCP tools"""
        
        @self.mcp.tool()
        def create_project(name, template=None):
            """Creates a new REAPER project."""
            try:
                # Send action to create new project
                self.client.send_message("/action", [ACTION_NEW_PROJECT])
                time.sleep(0.5)  # Give REAPER time to create the project
                
                # Set project name by saving it
                if name:
                    save_path = os.path.join(os.path.expanduser("~/Documents/REAPER Projects"), f"{name}.rpp")
                    self.client.send_message("/action", [ACTION_SAVE_PROJECT])
                    time.sleep(0.3)
                    # We can't directly set the save path via OSC, but we can update our state
                    self.current_project["name"] = name
                    self.current_project["path"] = save_path
                
                # Request updated project info
                self.request_project_info()
                
                return {"success": True, "message": f"Created project: {name}"}
            except Exception as e:
                logger.error(f"Error creating project: {e}")
                return {"success": False, "error": str(e)}
        
        @self.mcp.tool()
        def create_track(name=None):
            """Creates a new track in the current project."""
            try:
                # Use action ID to create new track (more reliable)
                self.client.send_message("/action", [ACTION_INSERT_TRACK])
                time.sleep(0.5)  # Give REAPER time to create the track
                
                # Get track count and assume new track is at the end
                self.client.send_message("/track/count/get", None)
                time.sleep(0.2)
                
                # Set track name if provided
                if name and self.current_project["tracks"]:
                    track_index = len(self.current_project["tracks"]) - 1
                    self.client.send_message(f"/track/{track_index}/name", [name])
                    self.current_project["tracks"][track_index]["name"] = name
                
                # Refresh track list
                self.refresh_track_list()
                
                return {"success": True, "track_index": len(self.current_project["tracks"]) - 1}
            except Exception as e:
                logger.error(f"Error creating track: {e}")
                return {"success": False, "error": str(e)}
        
        @self.mcp.tool()
        def list_tracks():
            """Lists all tracks in the current project."""
            try:
                # Refresh track information
                self.refresh_track_list()
                
                tracks = []
                for i, track in enumerate(self.current_project["tracks"]):
                    tracks.append({
                        "index": i,
                        "name": track.get("name", f"Track {i+1}"),
                        "is_selected": False  # We don't have this info via OSC by default
                    })
                
                return {"success": True, "tracks": tracks}
            except Exception as e:
                logger.error(f"Error listing tracks: {e}")
                return {"success": False, "error": str(e)}
        
        @self.mcp.tool()
        def add_midi_note(track_index, note, start_time, duration, velocity=100):
            """Adds a MIDI note to a track."""
            try:
                # First, make sure we have a MIDI item at the right position
                # Select the track using action IDs (more reliable)
                self.select_track(track_index)
                
                # Insert new MIDI item using action ID
                self.client.send_message("/action", [ACTION_INSERT_MIDI_ITEM])
                time.sleep(0.5)
                
                # We can't directly add MIDI notes via OSC
                # This is a limitation of the OSC implementation
                
                return {"success": True, "message": f"Created MIDI item on track {track_index}. Note: Adding specific MIDI notes requires ReaScript or external MIDI file import."}
            except Exception as e:
                logger.error(f"Error adding MIDI note: {e}")
                return {"success": False, "error": str(e)}
        
        @self.mcp.tool()
        def get_project_info():
            """Gets information about the current project."""
            try:
                # Request updated project info
                self.request_project_info()
                self.refresh_track_list()
                
                return {
                    "success": True,
                    "name": self.current_project["name"],
                    "path": self.current_project["path"],
                    "track_count": len(self.current_project["tracks"]),
                    "selected_track_count": 0  # We don't have this info via OSC by default
                }
            except Exception as e:
                logger.error(f"Error getting project info: {e}")
                return {"success": False, "error": str(e)}
    
    def run(self, transport='stdio'):
        """Run the MCP server"""
        logger.info("Starting OSC-based REAPER MCP Server...")
        try:
            # Use specified transport for MCP protocol
            self.mcp.run(transport=transport)
        except Exception as e:
            logger.error(f"Error running MCP server: {e}")
            traceback.print_exc(file=sys.stderr)
            sys.exit(1)
def create_server(host="127.0.0.1", send_port=8000, receive_port=9000):
    """
    Create and return a REAPER OSC Server instance
    
    Args:
        host (str): The IP address of the REAPER instance
        send_port (int): The port REAPER listens on for OSC messages
        receive_port (int): The port we listen on for REAPER responses
        
    Returns:
        ReaperOSCServer: The REAPER OSC Server instance
    """
    return ReaperOSCServer(host, send_port, receive_port)
def main():
    """Main entry point for the REAPER OSC Server"""
    # Set up logging
    logging.basicConfig(level=logging.DEBUG, 
                        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
                        stream=sys.stderr)
    
    # Create and run the server
    server = create_server()
    server.run()
if __name__ == "__main__":
    main()