FL Studio device script · repair report
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.
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.
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
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.
self.Plugin(event, 0, 1)
Now
self.Plugin(event)
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.
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)
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.
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)
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.
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)
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.
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.
channel = channels.selectedChannel(1)
if channel == -1 or not plugins.isValid(channel) :
plugin_name = ''
else :
plugin_name = plugins.getPluginName(channel)
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.
@staticmethod
def _scale_to_byte(value):
try:
scaled = int(float(value) * 127 / 100)
except (TypeError, ValueError):
return 0
return min(127, max(0, scaled))
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.
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.
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.
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.
'BASSDRUM'
'Fruit kick'
'MiniSynth'
'PoiZone',
Now
'BASSDRUM',
'Fruit kick',
'MiniSynth',
'PoiZone',
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.
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.
WasKNOB_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
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.
tempo_str = str(round(mixer.getCurrentTempo(0) / 1000))
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.
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.
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)
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.
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.
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.
| # | File | Where | Severity | What was wrong |
|---|---|---|---|---|
| 1 | MiniLab3Dispatch.py | Dispatch | Behaviour | Reported an unowned key as processed, and discarded the callback's own answer |
| 2 | MiniLab3Process.py | OnCommandEvent | Behaviour | Dropped the inner dispatch result |
| 3 | MiniLab3Process.py | OnWheelEvent | Behaviour | Never said whether it forwarded, so pitch bend could be swallowed |
| 4 | MiniLab3Process.py | OnKnobEvent | Behaviour | Dropped the inner dispatch result |
| 5 | MiniLab3Process.py | OnDrumEvent | Crash | None written to data1, index past the pad matrix |
| 6 | MiniLab3Process.py | Plugin | Crash | Two value unpack from a three value return, event passed as a CC number |
| 7 | MiniLab3Process.py | ForwardAnalogLab | Crash | Called Plugin with three arguments |
| 8 | MiniLab3Process.py | SetParamValue | Behaviour | Unclamped SysEx byte |
| 9 | MiniLab3Process.py | DAWMemory, ArturiaMemory | Crash | No event parameter on a dispatcher callback |
| 10 | MiniLab3Process.py | module level | Hygiene | MEMORY never declared |
| 11 | MiniLab3Plugin.py | NATIVE_PLUGIN_LIST | Behaviour | Three missing commas merged three names into one |
| 12 | MiniLab3Plugin.py | Plugin | Crash | getPluginName with no validity guard |
| 13 | MiniLab3Plugin.py | Plugin | Crash | PARAM_MAP.get returned None into setParamValue |
| 14 | MiniLab3Plugin.py | Plugin | Crash | UnboundLocalError on any all-minus-one map |
| 15 | MiniLab3Plugin.py | SetParamValue | Behaviour | Log flooding print, unclamped byte |
| 16 | MiniLab3Display.py | _refresh_display | Crash | Unclamped byte from a string or None value |
| 17 | MiniLab3Navigation.py | imports | Hygiene | Unused circular import |
| 18 | MiniLab3Navigation.py | TapTempoRefresh | Behaviour | Tempo built by slicing digits |
| 19 | MiniLab3Navigation.py | SnapModeRefresh | Behaviour | Missing table entry left stale text on screen |
| 20 | device_MiniLab3.py | OnMidiMsg | Behaviour | handled set to False, so FL processed everything twice |
| 21 | device_MiniLab3.py | Sync | Behaviour | Unguarded 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.
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.
| Platform | Path |
|---|---|
| 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 ###
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