Skip to main content
Glama
piano_roll.py13.2 kB
"""Piano Roll tools for FL Studio - persistent note placement. This module provides tools for creating, editing, and deleting notes in FL Studio's piano roll. Unlike MIDI real-time note triggering, these tools create persistent notes by communicating with FL Studio's Piano Roll scripting API via JSON files. Communication flow: 1. MCP server writes requests to mcp_request.json 2. Keystroke trigger (Cmd+Opt+Y) executes FL Studio's ComposeWithLLM script 3. Script reads JSON, modifies piano roll, exports state to piano_roll_state.json """ from __future__ import annotations import json import os import platform from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from fastmcp import FastMCP def _get_fl_scripts_dir() -> Path: """Get the FL Studio Piano Roll scripts directory.""" system = platform.system() if system == "Darwin": base = Path.home() / "Documents" / "Image-Line" / "FL Studio" / "Settings" elif system == "Windows": base = Path.home() / "Documents" / "Image-Line" / "FL Studio" / "Settings" else: # Linux fallback (FL Studio doesn't officially support Linux) base = Path.home() / ".fl-studio" / "Settings" scripts_dir = base / "Piano roll scripts" scripts_dir.mkdir(parents=True, exist_ok=True) return scripts_dir def _get_request_file() -> Path: """Get the path to the MCP request JSON file.""" return _get_fl_scripts_dir() / "mcp_request.json" def _get_response_file() -> Path: """Get the path to the MCP response JSON file.""" return _get_fl_scripts_dir() / "mcp_response.json" def _get_state_file() -> Path: """Get the path to the piano roll state JSON file.""" return _get_fl_scripts_dir() / "piano_roll_state.json" def _write_request(request: dict | list) -> None: """Write a request to the MCP request file.""" request_file = _get_request_file() # Read existing requests if any existing = [] if request_file.exists(): try: with open(request_file) as f: data = json.load(f) if isinstance(data, list): existing = data elif isinstance(data, dict): existing = [data] except (json.JSONDecodeError, IOError): existing = [] # Append new request(s) if isinstance(request, list): existing.extend(request) else: existing.append(request) # Write back with open(request_file, "w") as f: json.dump(existing, f, indent=2) def _clear_request_file() -> None: """Clear the request file.""" request_file = _get_request_file() if request_file.exists(): request_file.unlink() def _read_state() -> dict | None: """Read the current piano roll state.""" state_file = _get_state_file() if not state_file.exists(): return None try: with open(state_file) as f: return json.load(f) except (json.JSONDecodeError, IOError): return None def _midi_to_note_name(midi: int) -> str: """Convert MIDI note number to note name.""" note_names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] note_name = note_names[midi % 12] octave = (midi // 12) - 1 return f"{note_name}{octave}" def register_piano_roll_tools(mcp: FastMCP) -> None: """Register piano roll tools with the MCP server.""" from fl_studio_mcp.utils.fl_trigger import get_trigger, trigger_fl_studio @mcp.tool() def fl_send_notes( notes: list[dict], mode: str = "add", auto_trigger: bool = True ) -> str: """Add or replace notes in the FL Studio piano roll. This creates persistent notes in the currently open piano roll pattern. Notes use quarter-note timing for simplicity. Args: notes: List of note objects with properties: - midi (int): MIDI note number (60 = C4/Middle C) - duration (float): Length in quarter notes (1.0 = quarter note) - time (float, optional): Start position in quarter notes (default 0) - velocity (float, optional): Velocity 0.0-1.0 (default 0.8) mode: "add" to add notes, "replace" to clear existing notes first auto_trigger: Whether to automatically trigger FL Studio (default True) Example notes: [ {"midi": 60, "duration": 1.0, "time": 0}, # C4 quarter note at beat 0 {"midi": 64, "duration": 1.0, "time": 0}, # E4 (chord with C4) {"midi": 67, "duration": 1.0, "time": 0}, # G4 (C major chord) {"midi": 60, "duration": 0.5, "time": 1.0}, # C4 eighth note at beat 1 ] """ if not notes: return "Error: No notes provided" # Validate notes for i, note in enumerate(notes): if "midi" not in note: return f"Error: Note {i} missing 'midi' field" if "duration" not in note: return f"Error: Note {i} missing 'duration' field" # Set defaults note.setdefault("time", 0) note.setdefault("velocity", 0.8) requests = [] # If replace mode, clear first if mode == "replace": requests.append({"action": "clear"}) # Add notes request requests.append({ "action": "add_notes", "notes": notes }) _write_request(requests) # Trigger FL Studio if requested trigger_info = "" if auto_trigger: trigger = get_trigger() if trigger.is_supported: success = trigger_fl_studio() if success: trigger_info = " FL Studio triggered successfully." else: trigger_info = f" Warning: Could not trigger FL Studio. Press {trigger.keystroke} manually." else: trigger_info = f" Auto-trigger not supported on {trigger.platform}. Press the trigger key manually." note_count = len(notes) note_summary = ", ".join( f"{_midi_to_note_name(n['midi'])}@{n.get('time', 0)}" for n in notes[:5] ) if note_count > 5: note_summary += f", ... ({note_count - 5} more)" return f"Queued {note_count} note(s): {note_summary}.{trigger_info}" @mcp.tool() def fl_send_chord( midi_notes: list[int], time: float = 0, duration: float = 1.0, velocity: float = 0.8, auto_trigger: bool = True ) -> str: """Add a chord (multiple simultaneous notes) to the FL Studio piano roll. This is a convenience function for adding multiple notes at the same time with the same duration. For more control, use fl_send_notes. Args: midi_notes: List of MIDI note numbers (e.g., [60, 64, 67] for C major) time: Start position in quarter notes (default 0) duration: Length in quarter notes for all notes (default 1.0) velocity: Velocity 0.0-1.0 for all notes (default 0.8) auto_trigger: Whether to automatically trigger FL Studio Example - C major chord at beat 0: fl_send_chord([60, 64, 67], time=0, duration=1.0) Example - Am7 chord at beat 2: fl_send_chord([57, 60, 64, 67], time=2, duration=2.0) """ if not midi_notes: return "Error: No MIDI notes provided" # Build chord notes with velocity included chord_notes = [ {"midi": midi, "velocity": velocity} for midi in midi_notes ] request = { "action": "add_chord", "time": time, "duration": duration, "notes": chord_notes } _write_request(request) # Trigger FL Studio if requested trigger_info = "" if auto_trigger: trigger = get_trigger() if trigger.is_supported: success = trigger_fl_studio() if success: trigger_info = " FL Studio triggered successfully." else: trigger_info = f" Warning: Could not trigger FL Studio. Press {trigger.keystroke} manually." note_names = ", ".join(_midi_to_note_name(n) for n in midi_notes) return f"Queued chord [{note_names}] at beat {time}, duration {duration}.{trigger_info}" @mcp.tool() def fl_delete_notes( notes: list[dict], auto_trigger: bool = True ) -> str: """Delete specific notes from the FL Studio piano roll. Args: notes: List of notes to delete, matching by midi and time: - midi (int): MIDI note number - time (float): Start position in quarter notes auto_trigger: Whether to automatically trigger FL Studio Example: [{"midi": 60, "time": 0}, {"midi": 64, "time": 0}] """ if not notes: return "Error: No notes specified for deletion" request = { "action": "delete_notes", "notes": notes } _write_request(request) trigger_info = "" if auto_trigger: trigger = get_trigger() if trigger.is_supported: success = trigger_fl_studio() if success: trigger_info = " FL Studio triggered successfully." else: trigger_info = f" Warning: Could not trigger FL Studio. Press {trigger.keystroke} manually." return f"Queued deletion of {len(notes)} note(s).{trigger_info}" @mcp.tool() def fl_clear_piano_roll(auto_trigger: bool = True) -> str: """Clear all notes from the FL Studio piano roll. Args: auto_trigger: Whether to automatically trigger FL Studio """ request = {"action": "clear"} _write_request(request) trigger_info = "" if auto_trigger: trigger = get_trigger() if trigger.is_supported: success = trigger_fl_studio() if success: trigger_info = " FL Studio triggered successfully." else: trigger_info = f" Warning: Could not trigger FL Studio. Press {trigger.keystroke} manually." return f"Queued clear all notes.{trigger_info}" @mcp.tool() def fl_get_piano_roll_state() -> dict: """Get the current state of notes in the FL Studio piano roll. Returns a dictionary containing: - ppq: Pulses per quarter note (ticks per beat) - notes: List of all notes with their properties Note: This reads from the last exported state. Trigger FL Studio (Cmd+Opt+Y on macOS) to refresh the state file after making changes. """ state = _read_state() if state is None: return { "error": "No piano roll state available. Make sure FL Studio's " "ComposeWithLLM script has been run at least once." } # Add human-readable note names if "notes" in state: for note in state["notes"]: if "midi" in note: note["note_name"] = _midi_to_note_name(note["midi"]) return state @mcp.tool() def fl_clear_request_queue() -> str: """Clear any pending note requests without executing them. Use this if you want to cancel queued changes before triggering FL Studio. """ _clear_request_file() return "Request queue cleared." @mcp.tool() def fl_trigger_script() -> str: """Manually trigger FL Studio to process pending note requests. This sends the keystroke (Cmd+Opt+Y on macOS, Ctrl+Alt+Y on Windows) to FL Studio to execute the ComposeWithLLM piano roll script. """ trigger = get_trigger() if not trigger.is_supported: return f"Error: Auto-trigger not supported on {trigger.platform}" success = trigger_fl_studio() if success: return "FL Studio triggered successfully. Notes should now appear in the piano roll." else: return f"Failed to trigger FL Studio. Try pressing {trigger.keystroke} manually." @mcp.tool() def fl_get_piano_roll_info() -> dict: """Get information about the Piano Roll integration status. Returns platform info, file paths, and whether auto-triggering is supported. """ trigger = get_trigger() return { "platform": trigger.platform, "auto_trigger_supported": trigger.is_supported, "trigger_keystroke": trigger.keystroke, "scripts_dir": str(_get_fl_scripts_dir()), "request_file": str(_get_request_file()), "state_file": str(_get_state_file()), "request_file_exists": _get_request_file().exists(), "state_file_exists": _get_state_file().exists(), }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/karl-andres/fl-studio-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server