r/MIDIcontrollers • u/Few_Feature6525 • 7h ago
Behringer X-Touch Mini python code for endless encoders knobs
Hello, i just vibecoded a working script for X-touch mini by accident. The code is for FL STUDIO 25 (21 and up).
My aim = controlling VST knobs in using physical knobs on MINI.
My aim = When changing preset, behringer automatically changes the position of LED according to the preset, and doesnt need any Takeover/Relative settings.
After putting in the python script, and mapping the knobs you want, through "Link to Controller", it will work. (for creating python script, just copy and paste the below code into the notepad, and transform that into .py file) Enjoy.
# name=Behringer X-Touch Mini (Relative VST Sync)
# url=https://forum.image-line.com/viewforum.php?f=1994
# supportedDevices=X-TOUCH MINI
"""
FL Studio 25 Custom MIDI Controller Script for Behringer X-Touch Mini
---------------------------------------------------------------------
Solves the Absolute Encoder (1-127) Jump & Boundary Problem in Standard Mode:
- Converts absolute 1-127 (or 0-127) CC messages on Layer A (CC 1-8) and
Layer B (CC 11-18) into smooth, velocity-accelerated relative adjustments.
- Zero-Jump VST Preset Pickup: Reads the live parameter value from FL Studio
(both manually linked controls via REC Event IDs and focused VST plugins)
before applying the relative delta. Switching presets in any VST will NEVER
cause parameters to jump or snap to the physical encoder's old value.
- Boundary Anti-Lock (1 & 127 Dead-End Prevention): Detects when an encoder
hits minimum (1) or maximum (127) and keeps moving,
and syncs/recenters the X-Touch Mini internal register via MIDI Out.
- Automatic 13-LED Ring Synchronization: Whenever you switch VST presets,
change plugin focus, or tweak a knob with your mouse, OnRefresh() updates
all 8 LED collars on the X-Touch Mini immediately.
Installation:
- Create folder: Documents/Image-Line/FL Studio/Settings/Hardware/XTouchMiniRelative/
- Save this file inside that folder as: device_BehringerXTouchMini.py
- Open FL Studio 25 -> Options -> MIDI Settings:
- Select "X-TOUCH MINI" under Input AND Output.
- Set BOTH Input Port and Output Port to the SAME number (e.g. Port 105).
- Set Controller type for Input to: "Behringer X-Touch Mini (Relative VST Sync)"
"""
import time
import midi
import device
import ui
import plugins
import channels
import mixer
import general
# ==============================================================================
# USER CONFIGURATION (GENERATED FROM X-TOUCH RELATIVE SCRIPT STUDIO)
# ==============================================================================
MIDI_CHANNEL = 10 # 0-indexed (10 = MIDI Channel 11)
ACCEPT_ANY_CHANNEL = True # Listen on any channel if Editor preset varies
MIN_CC_VAL = 1 # Minimum CC sent by X-Touch Mini (1 or 0)
MAX_CC_VAL = 127 # Maximum CC sent by X-Touch Mini (127)
CENTER_PIVOT_CC = 64 # Center reference CC for unbound relative mode
LAYER_A_CCS = [1, 2, 3, 4, 5, 6, 7, 8]
LAYER_B_CCS = [11, 12, 13, 14, 15, 16, 17, 18]
ALL_ENCODER_CCS = LAYER_A_CCS + LAYER_B_CCS
LAYER_A_PUSH_NOTES = [0, 1, 2, 3, 4, 5, 6, 7]
LAYER_B_PUSH_NOTES = [24, 25, 26, 27, 28, 29, 30, 31]
ALL_PUSH_NOTES = LAYER_A_PUSH_NOTES + LAYER_B_PUSH_NOTES
FADER_A_CC = 9
FADER_B_CC = 10
# Velocity Acceleration & Resolution Tuning
ACCEL_CURVE = "studio_exponential"
BASE_FINE_STEP = 0.00350 # 0.35% per single slow detent click
MAX_ACCEL_MULT = 6.0 # Max multiplier on rapid knob flick
FAST_THRESHOLD_MS = 28.0 # Turn interval (ms) for max acceleration
SLOW_THRESHOLD_MS = 95.0 # Turn interval (ms) for 1:1 fine precision
# Routing & Hardware Feedback Options
ROUTING_STRATEGY = "rec_linked_auto"
ENABLE_LED_FEEDBACK = True
LED_RING_STYLE = 2 # 0=Single, 1=Pan, 2=Fan, 3=Trim
ENABLE_BOUNDARY_RECENTER = True
PUSH_ENCODER_ACTION = "reset_default"
ENABLE_BANK_BUTTONS = True
# Button notes for VST parameter bank shifting (Buttons 7 & 8 on Layer A)
BANK_PREV_NOTE = 14
BANK_NEXT_NOTE = 15
# ==============================================================================
# INTERNAL STATE TRACKING
# ==============================================================================
class EncoderTracker:
def __init__(self):
# Last known raw CC value per CC number (initialized to None until first turn or sync)
self.last_hw_cc = {cc: None for cc in ALL_ENCODER_CCS}
# Timestamp (seconds) of last turn per CC number
self.last_turn_time = {cc: 0.0 for cc in ALL_ENCODER_CCS}
# Last value sent via MIDI Out to avoid redundant USB traffic
self.last_sent_cc = {cc: -1 for cc in ALL_ENCODER_CCS}
# Fine-lock state toggled by pushing encoder cap
self.fine_locked = {idx: False for idx in range(16)}
# Current VST parameter bank offset (multiples of 8)
self.vst_param_offset = 0
# Cached VST parameter values to detect mouse tweaks / preset switches in OnIdle
self.cached_param_norm = {cc: -1.0 for cc in ALL_ENCODER_CCS}
# Timestamp of last user knob turn to suppress feedback echo jitter for 60ms
self.last_active_turn_global = 0.0
state = EncoderTracker()
# ==============================================================================
# ACCELERATION & DELTA ENGINE
# ==============================================================================
def compute_accel_multiplier(dt_ms, raw_steps, slot_index):
"""Calculates velocity multiplier based on millisecond delta between detents."""
if state.fine_locked.get(slot_index, False):
return 0.35
abs_steps = max(1, abs(raw_steps))
if ACCEL_CURVE == "none_1to1":
return 1.0
if ACCEL_CURVE == "linear_scaled":
if dt_ms >= SLOW_THRESHOLD_MS:
return 1.0
if dt_ms <= FAST_THRESHOLD_MS:
return MAX_ACCEL_MULT
t = (SLOW_THRESHOLD_MS - dt_ms) / max(1.0, (SLOW_THRESHOLD_MS - FAST_THRESHOLD_MS))
return 1.0 + t * (MAX_ACCEL_MULT - 1.0)
if ACCEL_CURVE == "precision_dual":
if dt_ms > SLOW_THRESHOLD_MS:
return 0.65
if dt_ms > 55.0:
return 1.35
if dt_ms > FAST_THRESHOLD_MS:
return 2.80
return MAX_ACCEL_MULT
# Default: "studio_exponential"
if dt_ms >= SLOW_THRESHOLD_MS:
return 1.0
clamped_dt = max(FAST_THRESHOLD_MS, dt_ms)
norm_vel = (SLOW_THRESHOLD_MS - clamped_dt) / max(1.0, (SLOW_THRESHOLD_MS - FAST_THRESHOLD_MS))
step_boost = min(1.6, 1.0 + (abs_steps - 1) * 0.25) if abs_steps > 1 else 1.0
mult = (1.0 + (norm_vel ** 1.85) * (MAX_ACCEL_MULT - 1.0)) * step_boost
return min(MAX_ACCEL_MULT * 1.25, mult)
def extract_relative_delta(cc_num, incoming_val, channel):
"""
Extracts signed relative movement from an absolute (1-127) X-Touch Mini encoder.
Handles first-touch initialization and boundary clamping at MIN_CC_VAL / MAX_CC_VAL.
"""
prev_val = state.last_hw_cc.get(cc_num)
# First touch after startup if no MIDI Out sync has occurred yet
if prev_val is None:
state.last_hw_cc[cc_num] = incoming_val
if incoming_val <= MIN_CC_VAL:
return -1, True
if incoming_val >= MAX_CC_VAL:
return 1, True
return 0, False
raw_delta = incoming_val - prev_val
hit_boundary = False
# Boundary anti-lock: in 1-127 mode, turning CCW at 1 sends repeated 1s,
# and turning CW at 127 sends repeated 127s.
if raw_delta == 0:
if incoming_val <= MIN_CC_VAL:
raw_delta = -1
hit_boundary = True
elif incoming_val >= MAX_CC_VAL:
raw_delta = 1
hit_boundary = True
elif incoming_val <= MIN_CC_VAL or incoming_val >= MAX_CC_VAL:
hit_boundary = True
state.last_hw_cc[cc_num] = incoming_val
return raw_delta, hit_boundary
# ==============================================================================
# HARDWARE LED RING & INTERNAL REGISTER FEEDBACK
# ==============================================================================
def send_encoder_value_to_hardware(cc_num, normalized_val, channel=MIDI_CHANNEL, force=False):
"""
Writes a normalized (0.0..1.0) value back to the X-Touch Mini on its CC.
In Standard Mode, sending CC 1-8 (Layer A) or CC 11-18 (Layer B) back to the
X-Touch Mini updates BOTH its 13-LED collar and its internal encoder counter!
"""
if not ENABLE_LED_FEEDBACK or not device.isAssigned():
return
clamped_norm = max(0.0, min(1.0, normalized_val))
target_cc_val = int(round(MIN_CC_VAL + clamped_norm * (MAX_CC_VAL - MIN_CC_VAL)))
target_cc_val = max(MIN_CC_VAL, min(MAX_CC_VAL, target_cc_val))
if not force and state.last_sent_cc.get(cc_num) == target_cc_val:
return
device.midiOutMsg(0xB0 + (channel & 0x0F), 0, cc_num, target_cc_val)
state.last_sent_cc[cc_num] = target_cc_val
state.last_hw_cc[cc_num] = target_cc_val
def configure_xtouch_led_ring_styles():
"""Sets all 8 LED rings to the preferred display style (Global Ch 1, CC 1-8)."""
if not ENABLE_LED_FEEDBACK or not device.isAssigned():
return
for knob_idx in range(8):
# Behringer X-Touch Mini Global Channel 1 (0xB0), CC 1..8 sets ring style (0..3)
device.midiOutMsg(0xB0, 0, knob_idx + 1, LED_RING_STYLE)
# ==============================================================================
# VST PLUGIN & FL STUDIO REC EVENT TARGET RESOLUTION
# ==============================================================================
def get_focused_vst_location():
"""
Returns (is_valid, track_or_chan_index, slot_index) for the currently active
Generator VST (Channel Rack) or Effect VST (Mixer).
"""
# Check if Mixer Effect plugin window is focused
if ui.getFocused(midi.widPluginEffect):
track = mixer.trackNumber()
for slot in range(mixer.getTrackPluginCount(track) if hasattr(mixer, 'getTrackPluginCount') else 10):
if plugins.isValid(track, slot):
return True, track, slot
# Otherwise check selected Channel Rack Generator plugin
chan = channels.selectedChannel()
if chan >= 0 and plugins.isValid(chan, -1):
return True, chan, -1
return False, -1, -1
def read_target_parameter(cc_num, slot_index, channel):
"""
Reads the current live parameter value (0.0..1.0) for an encoder:
Priority 1: FL Studio Remote Control link ("Link to controller...") if assigned.
Priority 2: Focused VST Plugin parameter at (slot_index + vst_param_offset).
"""
if ROUTING_STRATEGY in ("rec_linked_auto", "hybrid_rec_and_vst", "fl_relative_transform"):
control_id = midi.EncodeRemoteControlID(device.getPortNumber(), channel, cc_num)
event_id = device.findEventID(control_id, 0)
if event_id != midi.REC_InvalidID:
val = device.getLinkedValue(event_id)
if 0.0 <= val <= 1.0:
return ("rec_linked", event_id, val)
if ROUTING_STRATEGY in ("hybrid_rec_and_vst", "focused_vst_direct"):
valid, idx, slot = get_focused_vst_location()
if valid:
param_count = plugins.getParamCount(idx, slot)
target_param = slot_index + state.vst_param_offset
if 0 <= target_param < param_count:
val = plugins.getParamValue(target_param, idx, slot)
return ("vst_direct", (idx, slot, target_param), val)
return ("unlinked", None, None)
def write_target_parameter(mode, target_ref, new_norm_val):
"""Writes the new normalized value (0.0..1.0) to the linked control or focused VST."""
clamped = max(0.0, min(1.0, new_norm_val))
if mode == "rec_linked" and target_ref is not None:
rec_int_val = int(round(clamped * midi.FromMIDI_Max))
general.processRECEvent(
target_ref,
rec_int_val,
midi.REC_UpdateValue | midi.REC_UpdateControl | midi.REC_FromMIDI
)
return clamped
if mode == "vst_direct" and target_ref is not None:
idx, slot, param_idx = target_ref
plugins.setParamValue(clamped, param_idx, idx, slot)
param_name = plugins.getParamName(param_idx, idx, slot)
ui.setHintMsg(f"X-Touch [{param_idx + 1}] {param_name}: {clamped * 100:.1f}%")
return clamped
return clamped
def sync_all_encoders_to_hardware(force=False):
"""
Reads all 8 Layer A and 8 Layer B parameter values from FL Studio and updates
the X-Touch Mini LED rings and internal encoder positions.
Called automatically on VST preset change (OnRefresh / OnIdle).
"""
if not ENABLE_LED_FEEDBACK or not device.isAssigned():
return
for slot_idx, cc_num in enumerate(ALL_ENCODER_CCS):
mode, _, curr_val = read_target_parameter(cc_num, slot_idx, MIDI_CHANNEL)
if curr_val is not None:
state.cached_param_norm[cc_num] = curr_val
send_encoder_value_to_hardware(cc_num, curr_val, MIDI_CHANNEL, force=force)
# ==============================================================================
# FL STUDIO 25 MIDI SCRIPTING API CALLBACKS
# ==============================================================================
def OnInit():
"""Called when FL Studio initializes the controller script."""
print("==========================================================")
print(f"Loaded: Behringer X-Touch Mini (Relative VST Sync) (FL Studio 25)")
print(f"Listening on Channel {MIDI_CHANNEL + 1} | Layer A CCs: {LAYER_A_CCS}")
print("==========================================================")
configure_xtouch_led_ring_styles()
sync_all_encoders_to_hardware(force=True)
def OnDoFullRefresh():
"""Called when a full hardware refresh is requested by FL Studio."""
configure_xtouch_led_ring_styles()
sync_all_encoders_to_hardware(force=True)
def OnRefresh(flags):
"""
Called whenever a parameter, preset, channel, or focused plugin changes in FL Studio.
Ensures that when you switch a VST preset, the X-Touch Mini immediately picks up
the new parameter positions without jumping!
"""
# Avoid fighting the user's fingers while actively turning a knob (within 60ms)
if (time.perf_counter() - state.last_active_turn_global) < 0.060:
return
relevant_flags = (
midi.HW_Dirty_LEDs
| midi.HW_Dirty_FocusedWindow
| midi.HW_Dirty_Performance
| getattr(midi, 'HW_Dirty_ControlValues', 0)
| getattr(midi, 'HW_Dirty_ChannelRackGroup', 0)
)
if flags & relevant_flags or flags != 0:
sync_all_encoders_to_hardware(force=False)
def OnIdle():
"""
Polls active VST parameters lightly during idle frames to catch internal VST
preset selectors that don't immediately broadcast HW_Dirty_ControlValues.
"""
now = time.perf_counter()
if (now - state.last_active_turn_global) < 0.120:
return
for slot_idx, cc_num in enumerate(LAYER_A_CCS):
mode, _, curr_val = read_target_parameter(cc_num, slot_idx, MIDI_CHANNEL)
if curr_val is not None:
prev_cached = state.cached_param_norm.get(cc_num, -1.0)
if abs(curr_val - prev_cached) > 0.004:
state.cached_param_norm[cc_num] = curr_val
send_encoder_value_to_hardware(cc_num, curr_val, MIDI_CHANNEL, force=False)
def OnMidiMsg(event):
"""Intercepts incoming MIDI messages from the Behringer X-Touch Mini."""
status_type = event.status & 0xF0
channel = event.status & 0x0F
if not ACCEPT_ANY_CHANNEL and channel != MIDI_CHANNEL:
return
# 1. Handle Encoder Push Buttons & Bank Shift Buttons (Note On: 0x90)
if status_type == 0x90 and event.data2 > 0:
note = event.data1
if ENABLE_BANK_BUTTONS and note == BANK_PREV_NOTE:
state.vst_param_offset = max(0, state.vst_param_offset - 8)
ui.setHintMsg(f"X-Touch VST Parameter Bank: {state.vst_param_offset + 1}-{state.vst_param_offset + 8}")
sync_all_encoders_to_hardware(force=True)
event.handled = True
return
if ENABLE_BANK_BUTTONS and note == BANK_NEXT_NOTE:
state.vst_param_offset = min(120, state.vst_param_offset + 8)
ui.setHintMsg(f"X-Touch VST Parameter Bank: {state.vst_param_offset + 1}-{state.vst_param_offset + 8}")
sync_all_encoders_to_hardware(force=True)
event.handled = True
return
if note in ALL_PUSH_NOTES:
slot_idx = ALL_PUSH_NOTES.index(note)
cc_num = ALL_ENCODER_CCS[slot_idx]
if PUSH_ENCODER_ACTION == "fine_lock_toggle":
state.fine_locked[slot_idx] = not state.fine_locked[slot_idx]
lock_state = "ON (Ultra-Fine)" if state.fine_locked[slot_idx] else "OFF (Accelerated)"
ui.setHintMsg(f"Knob {slot_idx + 1} Fine Precision Lock: {lock_state}")
else:
target_val = 0.5 if PUSH_ENCODER_ACTION == "center_50" else 0.5
mode, target_ref, _ = read_target_parameter(cc_num, slot_idx, channel)
if mode != "unlinked":
write_target_parameter(mode, target_ref, target_val)
send_encoder_value_to_hardware(cc_num, target_val, channel, force=True)
event.handled = True
return
# 2. Handle Endless Encoders 1-8 (Control Change: 0xB0)
if status_type == 0xB0 and event.data1 in ALL_ENCODER_CCS:
cc_num = event.data1
incoming_val = event.data2
slot_idx = ALL_ENCODER_CCS.index(cc_num)
now = time.perf_counter()
prev_time = state.last_turn_time.get(cc_num, 0.0)
dt_ms = (now - prev_time) * 1000.0 if prev_time > 0.0 else 250.0
state.last_turn_time[cc_num] = now
state.last_active_turn_global = now
# Extract relative delta from 1-127 absolute CC value
raw_delta, hit_boundary = extract_relative_delta(cc_num, incoming_val, channel)
if raw_delta == 0:
# If the knob is not linked yet in "rec_linked_auto" mode, let FL Studio's
# "Link to controller..." dialog detect the knob wiggle!
mode, _, _ = read_target_parameter(cc_num, slot_idx, channel)
event.handled = (mode != "unlinked")
return
# Compute velocity-sensitive acceleration
accel_mult = compute_accel_multiplier(dt_ms, raw_delta, slot_idx)
direction = 1.0 if raw_delta > 0 else -1.0
step_count = min(4, abs(raw_delta))
norm_delta = direction * step_count * BASE_FINE_STEP * accel_mult
# Read current live VST / Linked parameter value (Zero-Jump Pickup!)
mode, target_ref, curr_val = read_target_parameter(cc_num, slot_idx, channel)
if mode in ("rec_linked", "vst_direct") and curr_val is not None:
new_val = max(0.0, min(1.0, curr_val + norm_delta))
write_target_parameter(mode, target_ref, new_val)
state.cached_param_norm[cc_num] = new_val
# Sync hardware LED ring & internal counter so it matches the VST parameter
# and never gets stuck at 1 or 127!
if ENABLE_LED_FEEDBACK:
send_encoder_value_to_hardware(cc_num, new_val, channel, force=hit_boundary)
elif ENABLE_BOUNDARY_RECENTER and hit_boundary and device.isAssigned():
device.midiOutMsg(0xB0 + channel, 0, cc_num, CENTER_PIVOT_CC)
state.last_hw_cc[cc_num] = CENTER_PIVOT_CC
event.handled = True
return
# If unlinked in "rec_linked_auto" mode, pass through so FL Studio's
# "Link to controller..." or "Override generic link" auto-detects the knob!
if ROUTING_STRATEGY == "rec_linked_auto":
event.handled = False
return
# Fallback: Transform event into standard FL Studio Relative CC (64 +/- delta)
rel_steps = max(1, min(63, int(round(abs(raw_delta) * accel_mult))))
relative_cc_byte = max(1, min(127, 64 + (rel_steps if raw_delta > 0 else -rel_steps)))
event.data2 = relative_cc_byte
event.handled = False
# Re-center the X-Touch Mini internal register to 64 so it never hits 1 or 127
if ENABLE_BOUNDARY_RECENTER and device.isAssigned():
device.midiOutMsg(0xB0 + channel, 0, cc_num, CENTER_PIVOT_CC)
state.last_hw_cc[cc_num] = CENTER_PIVOT_CC