FL Studio device script · repair report

Arturia MiniLab 3

Twenty one repairs across six of the ten script files. Seven of them were live crashes that took the whole controller script down inside FL Studio. Every fix is proven by a stub harness that fakes the FL Studio API and drives the broken code paths directly.

Script version 1.0.8 Files touched 6 of 10 Harness 17 of 17 passing Date 17 August 2026

Proof of repair

The harness stubs every FL Studio module the script imports, then runs seventeen scenarios against the real code. It ran against the untouched backup first, so the failures below are reproduced rather than assumed.

Original backup, scenarios passing 7 of 17
Original backup, exit code 1
Original backup, malformed SysEx frames 16
Repaired folder, scenarios passing 17 of 17
Repaired folder, exit code 0
Repaired folder, malformed SysEx frames 0
Analog Lab CC forwarding, both builds 2 and 2, unchanged

Those sixteen malformed frames matter on their own. A parameter reading above 1.0 produced the byte 0xF1 inside a SysEx frame, and 0xF1 is a MIDI System Real Time status byte. The MiniLab 3 would read it as the end of the message and drop the rest of the frame.

first offender: f0 00 20 6b 7f 42 21 10 00 07 00 f1 f7

The seven crashes

Crash Analog Lab knob on a stock plugin MiniLab3Process.py:516

Plugin() accepts the event and nothing else. The call site passed three arguments, so every Analog Lab knob turn raised TypeError whenever the selected channel held a plugin outside the V Collection list. That covers CC 9, 16, 17, 18, 19, 71, 74, 76, 77, 82, 83, 85, 93 and 112 through 115.

Was
self.Plugin(event, 0, 1)
Now
self.Plugin(event)

Crash Unmapped CC reached setParamValue MiniLab3Plugin.py:831

PARAM_MAP holds thirteen keys. Any other CC number returned None from .get(), and None != -1 is true, so the code walked straight into the mapped branch and handed None to FL as a parameter index.

Was
PLUGIN_PARAM = PARAM_MAP.get(cle)
if PLUGIN_PARAM != -1 :
    plugins.setParamValue(value, PLUGIN_PARAM, channels.selectedChannel())
Now
PLUGIN_PARAM = PARAM_MAP.get(cle, -1)
if PLUGIN_PARAM != -1 :
    plugins.setParamValue(hw_value/127, PLUGIN_PARAM, channel)

Crash Plugins with no mapped knobs MiniLab3Plugin.py:853

The refresh branch only bound parameter, value and mapped inside a conditional. Six of the stock maps set all eight knob slots to -1, so the loop assigned nothing and the return line raised UnboundLocalError. Fruity Keyboard Controller, BooBass, Autogun, PLUCKED!, Fruit kick and Fruity Dance all hit this.

Now
cle = str(hw_param)
mapped = 0
parameter = ("Knob " if hw_param in KNOB_ID else "Fader ") + str(PARAM_ID.get(cle))
value = str(hw_value)

Crash Pad note outside the 36 to 51 bank MiniLab3Process.py:255

Two failures in one line. FPC_MAP.get() wrote None into event.data1, and PAD_MATRIX_STATE[note - 36] ran past the end of a sixteen slot list. Shift the pads one octave and the script dies on the first hit.

Was
index = event.data1
if event.status == 153 :
    self.PadOn(index)
    event.data1 = FPC_MAP.get(str(event.data1))
Now
index = event.data1
mapped_note = FPC_MAP.get(str(index))
if mapped_note is None :
    event.handled = False
    return False
if event.status == 153 :
    self.PadOn(index)

Crash Sysex memory callbacks MiniLab3Process.py:571

DAWMemory and ArturiaMemory are registered in the sysex dispatcher, which calls every callback with the event. Both were declared taking self alone, so wiring that dispatcher up would raise TypeError on the first message. Both now accept the event.

Crash Knob moved with no valid plugin MiniLab3Plugin.py:98

plugins.getPluginName(channels.selectedChannel()) ran with no guard at all. selectedChannel() returns 0 rather than -1 on an empty rack, so a knob moved before any channel was loaded reached into a channel that holds nothing.

Now
channel = channels.selectedChannel(1)
if channel == -1 or not plugins.isValid(channel) :
    plugin_name = ''
else :
    plugin_name = plugins.getPluginName(channel)

Crash Display fed a string, a None or an overshoot MiniLab3Display.py:132

The encoder and fader screens ran int(int(value)*127/100) straight into bytes(). Value arrives as a string from the plugin pages and as None when nothing is mapped, and a parameter reading above 1.0 pushed the byte past 127. All three paths now route through one clamped helper.

Now
@staticmethod
def _scale_to_byte(value):
    try:
        scaled = int(float(value) * 127 / 100)
    except (TypeError, ValueError):
        return 0
    return min(127, max(0, scaled))

Behaviour repairs

Behaviour Every control was processed twice device_MiniLab3.py:115

This is the headline defect. event.handled already defaults to False, so setting it to False was a no operation. Every button and knob the script consumed was then handed to FL Studio's own MIDI handling for a second pass. That is where doubled transport hits and jumping knob values come from.

Was
if _processor.ProcessEvent(event):
    event.handled = False
Now
if _processor.ProcessEvent(event):
    event.handled = True

Flipping that flag alone would swallow every CC on the port, including the mod wheel and anything the user assigns by hand. Three call sites were changed so the answer is honest. Dispatch() now reports False for a key it does not own, OnCommandEvent passes the inner result up instead of dropping it, and OnWheelEvent claims the event only when it actually forwarded to Analog Lab.

Now
def Dispatch(self, event):
    key = self._transform_fn(event)
    if key not in self._dispatch_map:
        return False
    callback_fn, filter_fn = self._dispatch_map[key]
    if not filter_fn(event):
        return True
    result = callback_fn(event)
    if result is None:
        return True
    return bool(result)

The harness holds that line in both directions. An unmapped CC 22 stays available to FL, CC 106 Stop is consumed, a V Collection pitch bend still forwards, and a pitch bend on an ordinary channel still reaches FL so bends keep working.

Behaviour Three plugins fell out of the list MiniLab3Plugin.py:68

Three missing commas. Python joins adjacent string literals, so 'BASSDRUM' 'Fruit kick' 'MiniSynth' became the single entry 'BASSDRUMFruit kickMiniSynth'. Knob feedback never engaged for BASSDRUM, Fruit kick or MiniSynth, because the name test could never match.

Was
'BASSDRUM'
'Fruit kick'
'MiniSynth'
'PoiZone',
Now
'BASSDRUM',
'Fruit kick',
'MiniSynth',
'PoiZone',

Behaviour Debug print on every refresh MiniLab3Plugin.py:887

SetParamValue carried a bare print(param, value). PluginParamReturn calls it eight times per OnRefresh, so it flooded the FL Studio script log continuously while a stock plugin was selected. Removed, and the byte is clamped in the same pass.

Behaviour Wrong unpack in the knob refresh branch MiniLab3Process.py:494

The branch unpacked two values from a function that always returns three, and passed the event object where a CC number belongs. Dead in the shipped mapping, live the moment CC 29 is routed differently.

Was
KNOB_HW_VALUE, mapped = MiniLab3Plugin.Plugin(event, 0, 1)
Now
parameter, value, mapped = MiniLab3Plugin.Plugin(event.data1, event.data2, 0)
KNOB_HW_VALUE = MiniLab3Plugin.KNOB_HW_VALUE

Behaviour Tempo read as sliced digits MiniLab3Navigation.py:246

getCurrentTempo returns BPM multiplied by a thousand. The old code chose two or three characters off the front of the string, so 99.5 BPM displayed as 995 BPM. Now it divides and rounds.

Now
tempo_str = str(round(mixer.getCurrentTempo(0) / 1000))

Behaviour Snap mode 2 showed stale text MiniLab3Navigation.py:275

The snap table has no entry for mode 2, so the lookup returned None, and a None line leaves the previous text on screen. The display then reported the wrong snap mode with no sign anything went wrong. It now falls back to a labelled value.

Behaviour Channel name sanitising device_MiniLab3.py:79

Sync() called getChannelName without checking for a selection, and rebuilt the string one slice at a time inside a loop that constructed a fresh range object per character. Replaced with a guarded single pass that maps anything above ASCII to a question mark.

Now
active_index = channels.selectedChannel(1)
if active_index != -1 :
    channel_name = channels.getChannelName(active_index)
else :
    channel_name = ''
channel_name = ''.join(c if ord(c) < 128 else '?' for c in channel_name)

Structural cleanup

Hygiene Circular import removed MiniLab3Navigation.py:19

MiniLab3Navigation imported MiniLab3Process, which imports NavigationMode back out of Navigation. The alias was never referenced anywhere in the file, so the cycle bought nothing and left the loader holding a half built module. Deleted.

Hygiene MEMORY declared before use MiniLab3Process.py:141

The two memory callbacks wrote to a global that no module level statement ever created. Declared alongside the other constants so it exists before the first sysex arrives.

Hygiene Knob feedback byte clamped MiniLab3Process.py:565

SetParamValue in the processor built a SysEx byte from a raw parameter reading, with the same overflow the plugin module had. Clamped to 0 through 127 for consistency.

Every change in one table

#FileWhereSeverityWhat was wrong
1MiniLab3Dispatch.pyDispatchBehaviourReported an unowned key as processed, and discarded the callback's own answer
2MiniLab3Process.pyOnCommandEventBehaviourDropped the inner dispatch result
3MiniLab3Process.pyOnWheelEventBehaviourNever said whether it forwarded, so pitch bend could be swallowed
4MiniLab3Process.pyOnKnobEventBehaviourDropped the inner dispatch result
5MiniLab3Process.pyOnDrumEventCrashNone written to data1, index past the pad matrix
6MiniLab3Process.pyPluginCrashTwo value unpack from a three value return, event passed as a CC number
7MiniLab3Process.pyForwardAnalogLabCrashCalled Plugin with three arguments
8MiniLab3Process.pySetParamValueBehaviourUnclamped SysEx byte
9MiniLab3Process.pyDAWMemory, ArturiaMemoryCrashNo event parameter on a dispatcher callback
10MiniLab3Process.pymodule levelHygieneMEMORY never declared
11MiniLab3Plugin.pyNATIVE_PLUGIN_LISTBehaviourThree missing commas merged three names into one
12MiniLab3Plugin.pyPluginCrashgetPluginName with no validity guard
13MiniLab3Plugin.pyPluginCrashPARAM_MAP.get returned None into setParamValue
14MiniLab3Plugin.pyPluginCrashUnboundLocalError on any all-minus-one map
15MiniLab3Plugin.pySetParamValueBehaviourLog flooding print, unclamped byte
16MiniLab3Display.py_refresh_displayCrashUnclamped byte from a string or None value
17MiniLab3Navigation.pyimportsHygieneUnused circular import
18MiniLab3Navigation.pyTapTempoRefreshBehaviourTempo built by slicing digits
19MiniLab3Navigation.pySnapModeRefreshBehaviourMissing table entry left stale text on screen
20device_MiniLab3.pyOnMidiMsgBehaviourhandled set to False, so FL processed everything twice
21device_MiniLab3.pySyncBehaviourUnguarded selection, per character range allocation

Four files were read and left alone, because nothing in them was broken: MiniLab3Pages.py, MiniLab3Connexion.py, MiniLab3Return.py and ArturiaVCOL.py.

Installing it

FL Studio is not installed on this Mac. The repairs were verified against a stub of the FL Studio API rather than inside the DAW, so the last mile is a real hardware test on a machine that runs FL Studio.

Drop the whole folder, all ten files together, into the FL Studio hardware scripts directory.

PlatformPath
macOS~/Documents/Image-Line/FL Studio/Settings/Hardware/
Windows%USERPROFILE%\Documents\Image-Line\FL Studio\Settings\Hardware\

Then open FL Studio, go to Options and MIDI settings, select the MiniLab 3 DAW port, and set its controller type to Arturia MiniLab 3. Set the MiniLab 3 itself to DAW mode. Watch the script output window on load: a clean load prints both status lines and nothing else.

Loaded MIDI script for Arturia MiniLab 3
### Successfully created class objects ###
### Messages successfully sent to MINILAB3 ###

How the harness works

FL Studio device scripts import ten modules that only exist inside FL Studio, so none of this code can be imported on a plain Python install. The harness builds each of those modules as a stub, wires in state the scenarios can steer, then imports the real script and calls into it.

Two stub choices decide whether the harness is worth anything. plugins.setParamValue rejects a non integer parameter index, and plugins.getPluginName raises when the channel holds nothing. Both mirror what FL actually does. A first pass with permissive stubs reported those two scenarios as passing on the broken code, which is exactly the false negative a stub harness exists to avoid.

One stub returns 1.9 from getParamValue on purpose. That single reading is what surfaced the sixteen malformed SysEx frames, since 127 multiplied by 1.9 lands on 241 and 241 is 0xF1.

scenarios: 17   passed: 17   failed: 0
sysex messages sent to the stub device: 87
CC forwarded to the Analog Lab port:    2
sysex frames containing a byte > 0x7F:  0