Repository: mixxxdj/mixxx
Pull request #14557: Make the Twister a 4 deck + FX controller
Author: login: SamWhited
Labels: name: controller mappings
name: needs review
Created: 2025-03-27T18:55:23Z | Merged: 2025-05-25T10:06:12Z

This lets the Twister work with 4 decks, and adds an effects layer by rewriting it in ComponentsJS.
It lets you select independently which side of the controller is being used for what (as opposed to changing the entire controller between decks 1, 2 and 3, 4), so you could be making changes to decks 1 and 4 at the same time, for example.

Corresponding manual PR: mixxxdj/manual#747


Diff:
diff --git a/res/controllers/DJ TechTools MIDI Fighter Twister.midi.xml b/res/controllers/DJ TechTools MIDI Fighter Twister.midi.xml
index 273de7096ea5..748cefcfab2d 100644
--- a/res/controllers/DJ TechTools MIDI Fighter Twister.midi.xml	
+++ b/res/controllers/DJ TechTools MIDI Fighter Twister.midi.xml	
@@ -2,286 +2,439 @@
 <MixxxControllerPreset mixxxVersion="" schemaVersion="1">
   <info>
     <name>DJ TechTools MIDI Fighter Twister</name>
-    <author>tock203</author>
-    <description>for 2 deck use</description>
-    <manual>dj_techtools_midi_figher_twister</manual>
+    <author>tock203, Sam Whited</author>
+    <description>Control 4 decks with the MIDI Fighter Twister.</description>
+    <manual>dj_techtools_midi_fighter_twister</manual>
+    <forums>https://mixxx.discourse.group/t/mapping-the-midifighter-twister/16253</forums>
+    <devices>
+      <product protocol="midi" vendor_id="0x2580" product_id="0x0007"/>
+    </devices>
   </info>
+  <settings>
+    <option variable="vuMeter" type="boolean" label="Use gain knob as vu meter" default="false">
+      <description>
+        Show instantaneous volume on the 11 segment LEDs around the gain knob instead of the position of the knob.
+      </description>
+    </option>
+    <option variable="beatColor" type="enum" label="Beat Indicator Color">
+      <description>
+        Use the tempo knob LED as a beat indicator when set.
+      </description>
+      <value label="Off" default="true">off</value>
+      <value label="Firmware Default">127</value>
+      <value color="#0040f0" label="Blue">1</value>
+      <value color="#3ec8e6" label="Celeste">32</value>
+      <value color="#008080" label="Teal">40</value>
+      <value color="#2fb340" label="Green">50</value>
+      <value color="#3fff58" label="Lime">60</value>
+      <value color="#e9c600" label="Yellow">66</value>
+      <value color="#f07800" label="Orange">74</value>
+      <value color="#b90908" label="Red">85</value>
+      <value color="#ff00ff" label="Fuscia">100</value>
+      <value color="#a400c0" label="Purple">108</value>
+    </option>
+    <option variable="peakColor" type="enum" label="Peak Indicator Color">
+      <description>
+        Use the gain knob LED as a peak indicator when set.
+      </description>
+      <value label="Off">off</value>
+      <value label="Firmware Default">127</value>
+      <value color="#0040f0" label="Blue">1</value>
+      <value color="#3ec8e6" label="Celeste">32</value>
+      <value color="#008080" label="Teal">40</value>
+      <value color="#2fb340" label="Green">50</value>
+      <value color="#3fff58" label="Lime">60</value>
+      <value color="#e9c600" label="Yellow">66</value>
+      <value color="#f07800" label="Orange">74</value>
+      <value color="#b90908" label="Red" default="true">85</value>
+      <value color="#ff00ff" label="Fuscia">100</value>
+      <value color="#a400c0" label="Purple">108</value>
+    </option>
+    <option variable="defColor" type="enum" label="Default Value/Pushed Color">
+      <value label="Firmware Default" default="true">127</value>
+      <value color="#0040f0" label="Blue">1</value>
+      <value color="#3ec8e6" label="Celeste">32</value>
+      <value color="#008080" label="Teal">40</value>
+      <value color="#2fb340" label="Green">50</value>
+      <value color="#3fff58" label="Lime">60</value>
+      <value color="#e9c600" label="Yellow">66</value>
+      <value color="#f07800" label="Orange">74</value>
+      <value color="#b90908" label="Red">85</value>
+      <value color="#ff00ff" label="Fuscia">100</value>
+      <value color="#a400c0" label="Purple">108</value>
+    </option>
+    <option variable="relColor" type="enum" label="Released Color">
+      <value label="Firmware Default" default="true">0</value>
+      <value color="#0040f0" label="Blue">1</value>
+      <value color="#3ec8e6" label="Celeste">32</value>
+      <value color="#008080" label="Teal">40</value>
+      <value color="#2fb340" label="Green">50</value>
+      <value color="#3fff58" label="Lime">60</value>
+      <value color="#e9c600" label="Yellow">66</value>
+      <value color="#f07800" label="Orange">74</value>
+      <value color="#b90908" label="Red">85</value>
+      <value color="#ff00ff" label="Fuscia">100</value>
+      <value color="#a400c0" label="Purple">108</value>
+    </option>
+    <option variable="superOffColor" type="enum" label="Quick Effect Switch On Color">
+      <description>The color of the activated quick effect kill switch.</description>
+      <value label="Firmware Default" default="true">0</value>
+      <value color="#0040f0" label="Blue">1</value>
+      <value color="#3ec8e6" label="Celeste">32</value>
+      <value color="#008080" label="Teal">40</value>
+      <value color="#2fb340" label="Green">50</value>
+      <value color="#3fff58" label="Lime">60</value>
+      <value color="#e9c600" label="Yellow">66</value>
+      <value color="#f07800" label="Orange">74</value>
+      <value color="#b90908" label="Red">85</value>
+      <value color="#ff00ff" label="Fuscia">100</value>
+      <value color="#a400c0" label="Purple">108</value>
+    </option>
+    <option variable="superOnColor" type="enum" label="Quick Effect Switch Off Color">
+      <description>The color of the inactive quick effect kill switch.</description>
+      <value label="Firmware Default">127</value>
+      <value color="#0040f0" label="Blue">1</value>
+      <value color="#3ec8e6" label="Celeste">32</value>
+      <value color="#008080" label="Teal">40</value>
+      <value color="#2fb340" label="Green" default="true">50</value>
+      <value color="#3fff58" label="Lime">60</value>
+      <value color="#e9c600" label="Yellow">66</value>
+      <value color="#f07800" label="Orange">74</value>
+      <value color="#b90908" label="Red">85</value>
+      <value color="#ff00ff" label="Fuscia">100</value>
+      <value color="#a400c0" label="Purple">108</value>
+    </option>
+    <option variable="eqOffColor" type="enum" label="EQ Kill Switch On Color">
+      <description>The color of the activated EQ kill switch.</description>
+      <value label="Firmware Default" default="true">0</value>
+      <value color="#0040f0" label="Blue">1</value>
+      <value color="#3ec8e6" label="Celeste">32</value>
+      <value color="#008080" label="Teal">40</value>
+      <value color="#2fb340" label="Green">50</value>
+      <value color="#3fff58" label="Lime">60</value>
+      <value color="#e9c600" label="Yellow">66</value>
+      <value color="#f07800" label="Orange">74</value>
+      <value color="#b90908" label="Red">85</value>
+      <value color="#ff00ff" label="Fuscia">100</value>
+      <value color="#a400c0" label="Purple">108</value>
+    </option>
+    <option variable="eqOnColor" type="enum" label="EQ Kill Switch Off Color">
+      <description>The color of the inactive EQ kill switch.</description>
+      <value label="Firmware Default">127</value>
+      <value color="#0040f0" label="Blue">1</value>
+      <value color="#3ec8e6" label="Celeste">32</value>
+      <value color="#008080" label="Teal">40</value>
+      <value color="#2fb340" label="Green">50</value>
+      <value color="#3fff58" label="Lime">60</value>
+      <value color="#e9c600" label="Yellow">66</value>
+      <value color="#f07800" label="Orange">74</value>
+      <value color="#b90908" label="Red" default="true">85</value>
+      <value color="#ff00ff" label="Fuscia">100</value>
+      <value color="#a400c0" label="Purple">108</value>
+    </option>
+  </settings>
   <controller id="DJ TechTools MIDI Fighter Twister">
     <scriptfiles>
+      <file filename="midi-components-0.0.js" functionprefix=""/>
       <file functionprefix="MidiFighterTwister" filename="DJ TechTools-MIDI Fighter Twister-scripts.js"/>
     </scriptfiles>
     <controls>
+      <!-- Layer 1: EQ & Mixing -->
+
       <control>
         <group>[Channel1]</group>
-        <key>pregain</key>
-        <description>MIDI Learned from 245 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.gainKnob.input</key>
+        <description>Sets the decks pregain.</description>
         <status>0xB0</status>
         <midino>0x00</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel1]</group>
-        <key>pregain_set_default</key>
-        <description>MIDI Learned from 6 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.gainButton.input</key>
+        <description>Resets the decks pregain.</description>
         <status>0xB1</status>
         <midino>0x00</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel1]_Effect1]</group>
-        <key>parameter3</key>
-        <description>MIDI Learned from 1088 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.highKnob.input</key>
+        <description>Sets the decks high EQ.</description>
         <status>0xB0</status>
         <midino>0x01</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel1]_Effect1]</group>
-        <key>parameter3_set_default</key>
-        <description>MIDI Learned from 2 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.highButton.input</key>
+        <description>Resets the decks high EQ.</description>
         <status>0xB1</status>
         <midino>0x01</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel2]_Effect1]</group>
-        <key>parameter3</key>
-        <description>MIDI Learned from 1687 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.highKnob.input</key>
+        <description>Sets the decks high EQ.</description>
         <status>0xB0</status>
         <midino>0x02</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel2]_Effect1]</group>
-        <key>parameter3_set_default</key>
-        <description>MIDI Learned from 8 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.highButton.input</key>
+        <description>Resets the decks high EQ.</description>
         <status>0xB1</status>
         <midino>0x02</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel2]</group>
-        <key>pregain</key>
-        <description>MIDI Learned from 2 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.gainKnob.input</key>
+        <description>Sets the decks pregain.</description>
         <status>0xB0</status>
         <midino>0x03</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel2]</group>
-        <key>pregain_set_default</key>
-        <description>MIDI Learned from 2 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.gainButton.input</key>
+        <description>Resets the decks pregain.</description>
         <status>0xB1</status>
         <midino>0x03</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel1]</group>
-        <key>rate</key>
+        <key>MidiFighterTwister.controller.leftDeck.rateKnob.input</key>
+        <description>Sets the decks tempo.</description>
         <status>0xB0</status>
         <midino>0x04</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel1]</group>
-        <key>rate_set_default</key>
-        <description>MIDI Learned from 2 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.rateButton.input</key>
+        <description>Resets the decks tempo.</description>
         <status>0xB1</status>
         <midino>0x04</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel1]_Effect1]</group>
-        <key>parameter2</key>
-        <description>MIDI Learned from 1490 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.midKnob.input</key>
+        <description>Sets the decks mid EQ.</description>
         <status>0xB0</status>
         <midino>0x05</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel1]_Effect1]</group>
-        <key>parameter2_set_default</key>
-        <description>MIDI Learned from 4 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.midButton.input</key>
+        <description>Resets the decks mid EQ.</description>
         <status>0xB1</status>
         <midino>0x05</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel2]_Effect1]</group>
-        <key>parameter2</key>
-        <description>MIDI Learned from 2096 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.midKnob.input</key>
+        <description>Sets the decks mid EQ.</description>
         <status>0xB0</status>
         <midino>0x06</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel2]_Effect1]</group>
-        <key>parameter2_set_default</key>
-        <description>MIDI Learned from 10 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.midButton.input</key>
+        <description>Resets the decks mid EQ.</description>
         <status>0xB1</status>
         <midino>0x06</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel2]</group>
-        <key>rate</key>
+        <key>MidiFighterTwister.controller.rightDeck.rateKnob.input</key>
+        <description>Sets the decks tempo.</description>
         <status>0xB0</status>
         <midino>0x07</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel2]</group>
-        <key>rate_set_default</key>
-        <description>MIDI Learned from 2 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.rateButton.input</key>
+        <description>Resets the decks tempo.</description>
         <status>0xB1</status>
         <midino>0x07</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel1]</group>
-        <key>volume</key>
-        <description>MIDI Learned from 380 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.volumeKnob.input</key>
+        <description>Sets the decks volume.</description>
         <status>0xB0</status>
         <midino>0x08</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel1]</group>
-        <key>pfl</key>
-        <description>MIDI Learned from 2 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.pflButton.input</key>
+        <description>Toggles headphone output.</description>
         <status>0xB1</status>
         <midino>0x08</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel1]_Effect1]</group>
-        <key>parameter1</key>
-        <description>MIDI Learned from 1662 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.lowKnob.input</key>
+        <description>Sets the decks low EQ.</description>
         <status>0xB0</status>
         <midino>0x09</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel1]_Effect1]</group>
-        <key>parameter1_set_default</key>
-        <description>MIDI Learned from 6 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.lowButton.input</key>
+        <description>Resets the decks low EQ.</description>
         <status>0xB1</status>
         <midino>0x09</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel2]_Effect1]</group>
-        <key>parameter1</key>
-        <description>MIDI Learned from 339 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.lowKnob.input</key>
+        <description>Sets the decks low EQ.</description>
         <status>0xB0</status>
         <midino>0x0A</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[EqualizerRack1_[Channel2]_Effect1]</group>
-        <key>parameter1_set_default</key>
-        <description>MIDI Learned from 12 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.lowButton.input</key>
+        <description>Resets the decks low EQ.</description>
         <status>0xB1</status>
         <midino>0x0A</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel2]</group>
-        <key>volume</key>
-        <description>MIDI Learned from 498 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.volumeKnob.input</key>
+        <description>Sets the decks volume.</description>
         <status>0xB0</status>
         <midino>0x0B</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Channel2]</group>
-        <key>pfl</key>
-        <description>MIDI Learned from 4 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.pflButton.input</key>
+        <description>Toggles headphone output.</description>
         <status>0xB1</status>
         <midino>0x0B</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Master]</group>
-        <key>crossfader</key>
-        <description>MIDI Learned from 252 messages.</description>
+        <key>MidiFighterTwister.controller.crossfaderKnob.input</key>
+        <description>Adjust the crossfade between the decks.</description>
         <status>0xB0</status>
         <midino>0x0C</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[Master]</group>
-        <key>crossfader_set_default</key>
-        <description>MIDI Learned from 2 messages.</description>
+        <key>MidiFighterTwister.controller.crossfaderButton.input</key>
+        <description>Reset the crossfader to center.</description>
         <status>0xB1</status>
         <midino>0x0C</midino>
         <options>
-          <normal/>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Master]</group>
+        <key>MidiFighterTwister.controller.mainGainKnob.input</key>
+        <description>Adjust the main gain.</description>
+        <status>0xB0</status>
+        <midino>0x0F</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Master]</group>
+        <key>MidiFighterTwister.controller.mainGainButton.input</key>
+        <description>Reset the main gain.</description>
+        <status>0xB1</status>
+        <midino>0x0F</midino>
+        <options>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[QuickEffectRack1_[Channel1]]</group>
-        <key>super1</key>
-        <description>MIDI Learned from 256 messages.</description>
+        <key>MidiFighterTwister.controller.leftDeck.superKnob.input</key>
+        <description>Set the decks effect super knob.</description>
         <status>0xB0</status>
         <midino>0x0D</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[QuickEffectRack1_[Channel1]]</group>
-        <key>MidiFighterTwister.resetSuperKnob</key>
+        <key>MidiFighterTwister.controller.leftDeck.superButton.input</key>
+        <description>Reset the decks effect super knob.</description>
         <status>0xB1</status>
         <midino>0x0D</midino>
         <options>
@@ -290,23 +443,323 @@
       </control>
       <control>
         <group>[QuickEffectRack1_[Channel2]]</group>
-        <key>super1</key>
-        <description>MIDI Learned from 3 messages.</description>
+        <key>MidiFighterTwister.controller.rightDeck.superKnob.input</key>
+        <description>Set the decks effect super knob.</description>
         <status>0xB0</status>
         <midino>0x0E</midino>
         <options>
-          <normal/>
+          <script-binding/>
         </options>
       </control>
       <control>
         <group>[QuickEffectRack1_[Channel2]]</group>
-        <key>MidiFighterTwister.resetSuperKnob</key>
+        <key>MidiFighterTwister.controller.rightDeck.superButton.input</key>
+        <description>Reset the decks effect super knob.</description>
         <status>0xB1</status>
         <midino>0x0E</midino>
         <options>
           <script-binding/>
         </options>
       </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.toggleDeck</key>
+        <description>Toggle the left side of the controller between decks 1 and 3.</description>
+        <status>0xB3</status>
+        <midino>0x08</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.toggleDeck</key>
+        <description>Toggle the right side of the controller between decks 2 and 4.</description>
+        <status>0xB3</status>
+        <midino>0x0B</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+
+      <!-- Layer 2: Effects -->
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.toggleEffects</key>
+        <status>0xB3</status>
+        <midino>0x0E</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].enableOnChannelButtons["Channel1"].input</key>
+        <status>0xB1</status>
+        <midino>0x10</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].enableOnChannelButtons["Channel2"].input</key>
+        <status>0xB1</status>
+        <midino>0x14</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].enableOnChannelButtons["Headphone"].input</key>
+        <status>0xB1</status>
+        <midino>0x18</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].mixModeButton.input</key>
+        <status>0xB1</status>
+        <midino>0x1C</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].enableButtons[1].input</key>
+        <status>0xB1</status>
+        <midino>0x11</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].enableButtons[2].input</key>
+        <status>0xB1</status>
+        <midino>0x15</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].enableButtons[3].input</key>
+        <status>0xB1</status>
+        <midino>0x19</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].knobs[1].input</key>
+        <status>0xB0</status>
+        <midino>0x11</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].knobs[2].input</key>
+        <status>0xB0</status>
+        <midino>0x15</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].knobs[3].input</key>
+        <status>0xB0</status>
+        <midino>0x19</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].dryWetKnob.input</key>
+        <status>0xB0</status>
+        <midino>0x1D</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.fx[0].effectFocusButton.input</key>
+        <status>0xB1</status>
+        <midino>0x1D</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.toggleEffects</key>
+        <status>0xB3</status>
+        <midino>0x11</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].enableOnChannelButtons["Channel1"].input</key>
+        <status>0xB1</status>
+        <midino>0x13</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].enableOnChannelButtons["Channel2"].input</key>
+        <status>0xB1</status>
+        <midino>0x17</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].enableOnChannelButtons["Headphone"].input</key>
+        <status>0xB1</status>
+        <midino>0x1B</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].mixModeButton.input</key>
+        <status>0xB1</status>
+        <midino>0x1F</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].enableButtons[1].input</key>
+        <status>0xB1</status>
+        <midino>0x12</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].enableButtons[2].input</key>
+        <status>0xB1</status>
+        <midino>0x16</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].enableButtons[3].input</key>
+        <status>0xB1</status>
+        <midino>0x1A</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].knobs[1].input</key>
+        <status>0xB0</status>
+        <midino>0x12</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].knobs[2].input</key>
+        <status>0xB0</status>
+        <midino>0x16</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].knobs[3].input</key>
+        <status>0xB0</status>
+        <midino>0x1A</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].dryWetKnob.input</key>
+        <status>0xB0</status>
+        <midino>0x1E</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.fx[1].effectFocusButton.input</key>
+        <status>0xB1</status>
+        <midino>0x1E</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+
+      <!-- Shift buttons -->
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.toggleShift</key>
+        <description>Shift button for layer 1.</description>
+        <status>0xB3</status>
+        <midino>0x0A</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.toggleShift</key>
+        <description>Shift button for layer 1.</description>
+        <status>0xB3</status>
+        <midino>0x0D</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel1]</group>
+        <key>MidiFighterTwister.controller.toggleShift</key>
+        <description>Shift button for layer 2.</description>
+        <status>0xB3</status>
+        <midino>0x10</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
+      <control>
+        <group>[Channel2]</group>
+        <key>MidiFighterTwister.controller.toggleShift</key>
+        <description>Shift button for layer 2.</description>
+        <status>0xB3</status>
+        <midino>0x13</midino>
+        <options>
+          <script-binding/>
+        </options>
+      </control>
     </controls>
     <outputs/>
   </controller>
diff --git a/res/controllers/DJ TechTools-MIDI Fighter Twister-scripts.js b/res/controllers/DJ TechTools-MIDI Fighter Twister-scripts.js
index 5b76f1424095..13861dd4ea4c 100644
--- a/res/controllers/DJ TechTools-MIDI Fighter Twister-scripts.js	
+++ b/res/controllers/DJ TechTools-MIDI Fighter Twister-scripts.js	
@@ -1,85 +1,403 @@
-var MidiFighterTwister = {
-    init: function() {
-        var cc = 0xB0;
+"use strict";
 
-        function linearize(value) {
-            return Math.pow(value / 4, 0.5) * 127;
+// eslint-disable-next-line no-var
+var MidiFighterTwister;
+(function(MidiFighterTwister) {
+    const scaleHalfPlusOne = function(value) {
+        return (value + 1) / 2 * this.max;
+    };
+
+    const linearize = function(value) {
+        let max = 127;
+        if (this !== undefined && this.max !== undefined) {
+            max = this.max;
+        }
+        return Math.pow(value / 4, 0.5) * max;
+    };
+
+    const doubleLinearize = function(value) {
+        return linearize(value) * 2;
+    };
+
+    const indicatorConnect = function(color, key) {
+        return function() {
+            this.connections[0] = engine.makeConnection(this.group, this.outKey, this.output.bind(this));
+
+            if (color !== "off") {
+                this.connections[1] = engine.makeConnection(this.group, key, (value) => {
+                    if (value) {
+                        this.send(color);
+                    } else {
+                        const pregainDef = this.inGetParameter();
+                        this.send(pregainDef ? this.on : this.off);
+                    }
+                });
+            }
+        };
+    };
+
+    const multiSegConnect = function(enabled, key, scale) {
+        return function() {
+            this.connections[0] = engine.makeConnection(this.group, this.outKey, this.output.bind(this));
+
+            if (typeof scale !== "function") {
+                scale = this.outValueScale;
+            }
+            if (enabled) {
+                this.connections[1] = engine.makeConnection(this.group, key, (value) => {
+                    this.send(scale(value));
+                });
+            }
+        };
+    };
+
+    components.Button.prototype.on = engine.getSetting("defColor");
+    components.Button.prototype.off = engine.getSetting("relColor");
+
+    class Deck extends components.Deck {
+        midiModifier(value) {
+            // All midi values are for the left deck, we only modify them if
+            // we're constructing the right hand deck.
+            if (this.deckNumbers[0] === 1) {
+                return value;
+            }
+
+            // Even controls (ie. the first row) are mirrored to the far right
+            // row, three rows over.
+            if (value % 2 === 0) {
+                return value + 3;
+            }
+            // Odd controls (ie. the second row) are mirrored to the other inner
+            // row, one row over.
+            return value + 1;
+        }
+
+        constructor(deckNumbers) {
+            super(deckNumbers);
+
+            this.rateKnob = new components.Encoder({
+                group: `[Channel${this.deckNumbers[0]}]`,
+                midi: [0xB0, this.midiModifier(0x04)],
+                key: "rate",
+                outValueScale: scaleHalfPlusOne,
+            });
+            this.gainKnob = new components.Encoder({
+                group: `[Channel${this.deckNumbers[0]}]`,
+                midi: [0xB0, this.midiModifier(0x00)],
+                key: "pregain",
+                outValueScale: linearize,
+                connect: multiSegConnect(engine.getSetting("vuMeter"), "VuMeter", doubleLinearize),
+            });
+            this.volumeKnob = new components.Encoder({
+                group: `[Channel${this.deckNumbers[0]}]`,
+                midi: [0xB0, this.midiModifier(0x08)],
+                key: "volume",
+                outValueScale: doubleLinearize,
+            });
+            this.highKnob = new components.Encoder({
+                group: `[EqualizerRack1_[Channel${this.deckNumbers[0]}]_Effect1]`,
+                midi: [0xB0, this.midiModifier(0x01)],
+                key: "parameter3",
+                outValueScale: linearize,
+            });
+            this.midKnob = new components.Encoder({
+                group: `[EqualizerRack1_[Channel${this.deckNumbers[0]}]_Effect1]`,
+                midi: [0xB0, this.midiModifier(0x05)],
+                key: "parameter2",
+                outValueScale: linearize,
+            });
+            this.lowKnob = new components.Encoder({
+                group: `[EqualizerRack1_[Channel${this.deckNumbers[0]}]_Effect1]`,
+                midi: [0xB0, this.midiModifier(0x09)],
+                key: "parameter1",
+                outValueScale: linearize,
+            });
+            this.superKnob = new components.Encoder({
+                group: `[QuickEffectRack1_[Channel${this.deckNumbers[0]}]]`,
+                midi: [0xB0, this.midiModifier(0x0D)],
+                key: "super1",
+            });
+
+            this.rateButton = new components.Button({
+                group: `[Channel${this.deckNumbers[0]}]`,
+                midi: [0xB1, this.midiModifier(0x04)],
+                key: "rate_set_default",
+                connect: indicatorConnect(engine.getSetting("beatColor"), "beat_active"),
+            });
+
+            this.gainButton = new components.Button({
+                group: `[Channel${this.deckNumbers[0]}]`,
+                midi: [0xB1, this.midiModifier(0x00)],
+                key: "pregain_set_default",
+                connect: indicatorConnect(engine.getSetting("peakColor"), "PeakIndicator"),
+            });
+            // The volume button toggles the headphones, unlike the others which
+            // reset their control.
+            this.pflButton = new components.Button({
+                group: `[Channel${this.deckNumbers[0]}]`,
+                midi: [0xB1, this.midiModifier(0x08)],
+                type: components.Button.prototype.types.toggle,
+                key: "pfl",
+            });
+
+            const shiftFunc = function(newKey, onColor, offColor) {
+                return function() {
+                    this.inKey = newKey;
+                    this.outKey = newKey;
+                    this.type = components.Button.prototype.types.toggle;
+                    this.on = onColor;
+                    this.off = offColor;
+                    this.disconnect();
+                    this.connect();
+                    this.trigger();
+                };
+            };
+            const unshiftFunc = function() {
+                this.inKey = this.key;
+                this.outKey = this.key;
+                this.type = components.Button.prototype.types.push;
+                this.on = components.Button.prototype.on;
+                this.off = components.Button.prototype.off;
+                this.disconnect();
+                this.connect();
+                this.trigger();
+            };
+            this.highButton = new components.Button({
+                group: `[EqualizerRack1_[Channel${this.deckNumbers[0]}]_Effect1]`,
+                midi: [0xB1, this.midiModifier(0x01)],
+                key: "parameter3_set_default",
+                shift: shiftFunc("button_parameter3", engine.getSetting("eqOnColor"), engine.getSetting("eqOffColor")),
+                unshift: unshiftFunc,
+            });
+            this.midButton = new components.Button({
+                group: `[EqualizerRack1_[Channel${this.deckNumbers[0]}]_Effect1]`,
+                midi: [0xB1, this.midiModifier(0x05)],
+                key: "parameter2_set_default",
+                shift: shiftFunc("button_parameter2", engine.getSetting("eqOnColor"), engine.getSetting("eqOffColor")),
+                unshift: unshiftFunc,
+            });
+            this.lowButton = new components.Button({
+                group: `[EqualizerRack1_[Channel${this.deckNumbers[0]}]_Effect1]`,
+                midi: [0xB1, this.midiModifier(0x09)],
+                key: "parameter1_set_default",
+                shift: shiftFunc("button_parameter1", engine.getSetting("eqOnColor"), engine.getSetting("eqOffColor")),
+                unshift: unshiftFunc,
+            });
+            this.superButton = new components.Button({
+                group: `[QuickEffectRack1_[Channel${this.deckNumbers[0]}]]`,
+                midi: [0xB1, this.midiModifier(0x0D)],
+                key: "super1_set_default",
+                shift: shiftFunc("enabled", engine.getSetting("superOnColor"), engine.getSetting("superOffColor")),
+                unshift: unshiftFunc,
+            });
+        }
+    }
+
+    class Controller extends components.ComponentContainer {
+        constructor() {
+            super({});
+
+            this.leftDeck = new Deck([1, 3]);
+            this.rightDeck = new Deck([2, 4]);
+
+            // Layer 1 Controls
+
+            this.crossfaderKnob = new components.Encoder({
+                group: "[Master]",
+                midi: [0xB0, 0x0C],
+                key: "crossfader",
+                outValueScale: scaleHalfPlusOne,
+                unshift: function() {
+                    this.inKey = "crossfader";
+                    this.outKey = "crossfader";
+                    this.disconnect();
+                    this.connect();
+                    this.trigger();
+                },
+                shift: function() {
+                    this.inKey = "balance";
+                    this.outKey = "balance";
+                    this.disconnect();
+                    this.connect();
+                    this.trigger();
+                },
+            });
+            this.crossfaderButton = new components.Button({
+                group: "[Master]",
+                midi: [0xB1, 0x0C],
+                key: "crossfader_set_default",
+                unshift: function() {
+                    this.inKey = "crossfader_set_default";
+                },
+                shift: function() {
+                    this.inKey = "balance_set_default";
+                },
+            });
+            this.mainGainKnob = new components.Encoder({
+                group: "[Master]",
+                midi: [0xB0, 0x0F],
+                key: "gain",
+                outValueScale: linearize,
+                connect: multiSegConnect(engine.getSetting("vuMeter"), "VuMeter", doubleLinearize),
+                unshift: function() {
+                    this.inKey = "gain";
+                    this.outKey = "gain";
+                    this.disconnect();
+                    this.connect();
+                    this.trigger();
+                },
+                shift: function() {
+                    this.inKey = "headGain";
+                    this.outKey = "headGain";
+                    this.disconnect();
+                    this.connect();
+                    this.trigger();
+                },
+            });
+            this.mainGainButton = new components.Encoder({
+                group: "[Master]",
+                midi: [0xB1, 0x0F],
+                key: "gain_set_default",
+                connect: indicatorConnect(engine.getSetting("peakColor"), "PeakIndicator"),
+                unshift: function() {
+                    this.inKey = "gain_set_default";
+                },
+                shift: function() {
+                    this.inKey = "headGain_set_default";
+                },
+            });
+
+            // Layer 2 Controls
+            this.fx = [];
+            this.fx[0] = new components.EffectUnit([1, 3]);
+            this.fx[0].enableButtons[1].midi = [0xB1, 0x11];
+            this.fx[0].enableButtons[2].midi = [0xB1, 0x15];
+            this.fx[0].enableButtons[3].midi = [0xB1, 0x19];
+            this.fx[0].knobs[1].midi = [0xB0, 0x11];
+            this.fx[0].knobs[2].midi = [0xB0, 0x15];
+            this.fx[0].knobs[3].midi = [0xB0, 0x19];
+            this.fx[0].dryWetKnob.midi = [0xB0, 0x1D];
+            this.fx[0].effectFocusButton.midi = [0xB1, 0x1D];
+            this.fx[0].enableOnChannelButtons.addButton("Channel1");
+            this.fx[0].enableOnChannelButtons.addButton("Channel2");
+            this.fx[0].enableOnChannelButtons.addButton("Headphone");
+            this.fx[0].enableOnChannelButtons.Channel1.midi = [0xB1, 0x10];
+            this.fx[0].enableOnChannelButtons.Channel1.shift = function() {
+                this.inKey = "group_[Channel3]_enable";
+                this.outKey = "group_[Channel3]_enable";
+            };
+            this.fx[0].enableOnChannelButtons.Channel1.unshift = function() {
+                this.inKey = "group_[Channel1]_enable";
+                this.outKey = "group_[Channel1]_enable";
+            };
+            this.fx[0].enableOnChannelButtons.Channel2.midi = [0xB1, 0x14];
+            this.fx[0].enableOnChannelButtons.Channel2.shift = function() {
+                this.inKey = "group_[Channel4]_enable";
+                this.outKey = "group_[Channel4]_enable";
+            };
+            this.fx[0].enableOnChannelButtons.Channel2.unshift = function() {
+                this.inKey = "group_[Channel2]_enable";
+                this.outKey = "group_[Channel2]_enable";
+            };
+            this.fx[0].enableOnChannelButtons.Headphone.midi = [0xB1, 0x18];
+            this.fx[0].mixModeButton = new components.Button({
+                group: "[EffectRack1_EffectUnit1]",
+                key: "mix_mode",
+                midi: [0xB1, 0x1C],
+                type: components.Button.prototype.types.toggle,
+            });
+            this.fx[0].init();
+
+            this.fx[1] = new components.EffectUnit([2, 4]);
+            this.fx[1].enableButtons[1].midi = [0xB1, 0x12];
+            this.fx[1].enableButtons[2].midi = [0xB1, 0x16];
+            this.fx[1].enableButtons[3].midi = [0xB1, 0x1A];
+            this.fx[1].knobs[1].midi = [0xB0, 0x12];
+            this.fx[1].knobs[2].midi = [0xB0, 0x16];
+            this.fx[1].knobs[3].midi = [0xB0, 0x1A];
+            this.fx[1].dryWetKnob.midi = [0xB0, 0x1E];
+            this.fx[1].effectFocusButton.midi = [0xB1, 0x1E];
+            this.fx[1].enableOnChannelButtons.addButton("Channel1");
+            this.fx[1].enableOnChannelButtons.addButton("Channel2");
+            this.fx[1].enableOnChannelButtons.addButton("Headphone");
+            this.fx[1].enableOnChannelButtons.Channel1.midi = [0xB1, 0x13];
+            this.fx[1].enableOnChannelButtons.Channel1.shift = function() {
+                this.inKey = "group_[Channel3]_enable";
+                this.outKey = "group_[Channel3]_enable";
+            };
+            this.fx[1].enableOnChannelButtons.Channel1.unshift = function() {
+                this.inKey = "group_[Channel1]_enable";
+                this.outKey = "group_[Channel1]_enable";
+            };
+            this.fx[1].enableOnChannelButtons.Channel2.midi = [0xB1, 0x17];
+            this.fx[1].enableOnChannelButtons.Channel2.shift = function() {
+                this.inKey = "group_[Channel4]_enable";
+                this.outKey = "group_[Channel4]_enable";
+            };
+            this.fx[1].enableOnChannelButtons.Channel2.unshift = function() {
+                this.inKey = "group_[Channel2]_enable";
+                this.outKey = "group_[Channel2]_enable";
+            };
+            this.fx[1].enableOnChannelButtons.Headphone.midi = [0xB1, 0x1B];
+            this.fx[1].mixModeButton = new components.Button({
+                group: "[EffectRack1_EffectUnit2]",
+                key: "mix_mode",
+                midi: [0xB1, 0x1F],
+                type: components.Button.prototype.types.toggle,
+            });
+            this.fx[1].init();
+        }
+
+        toggleShift(_channel, _control, value, _status, _group) {
+            if (value) {
+                this.shift();
+            } else {
+                this.unshift();
+            }
         }
 
-        MidiFighterTwister.connections = [
-            engine.makeConnection("[Channel1]", "rate", function(value) {
-                midi.sendShortMsg(cc, 0x04, (value + 1) / 2 * 127);
-            }),
-            engine.makeConnection("[Channel2]", "rate", function(value) {
-                midi.sendShortMsg(cc, 0x07, (value + 1) / 2 * 127);
-            }),
-            engine.makeConnection("[Master]", "crossfader", function(value) {
-                var scaled = (value + 1) / 2 * 127;
-                midi.sendShortMsg(cc, 0x0C, scaled);
-            }),
-            engine.makeConnection("[Channel1]", "pregain", function(value) {
-                midi.sendShortMsg(cc, 0x00, linearize(value));
-            }),
-            engine.makeConnection("[Channel2]", "pregain", function(value) {
-                midi.sendShortMsg(cc, 0x03, linearize(value));
-            }),
-            engine.makeConnection("[Channel1]", "volume", function(value) {
-                midi.sendShortMsg(cc, 0x08, linearize(value) * 2);
-            }),
-            engine.makeConnection("[Channel2]", "volume", function(value) {
-                midi.sendShortMsg(cc, 0x0B, linearize(value) * 2);
-            }),
-            engine.makeConnection("[Channel1]", "pfl", function(value) {
-                midi.sendShortMsg(cc + 1, 0x08, value * 127);
-            }),
-            engine.makeConnection("[Channel2]", "pfl", function(value) {
-                midi.sendShortMsg(cc + 1, 0x0B, value * 127);
-            }),
-            // High EQ
-            engine.makeConnection("[EqualizerRack1_[Channel1]_Effect1]", "parameter3", function(value) {
-                midi.sendShortMsg(cc, 0x01, linearize(value));
-            }),
-            engine.makeConnection("[EqualizerRack1_[Channel2]_Effect1]", "parameter3", function(value) {
-                midi.sendShortMsg(cc, 0x02, linearize(value));
-            }),
-            // Mid EQ
-            engine.makeConnection("[EqualizerRack1_[Channel1]_Effect1]", "parameter2", function(value) {
-                midi.sendShortMsg(cc, 0x05, linearize(value));
-            }),
-            engine.makeConnection("[EqualizerRack1_[Channel2]_Effect1]", "parameter2", function(value) {
-                midi.sendShortMsg(cc, 0x06, linearize(value));
-            }),
-            // Low EQ
-            engine.makeConnection("[EqualizerRack1_[Channel1]_Effect1]", "parameter1", function(value) {
-                midi.sendShortMsg(cc, 0x09, linearize(value));
-            }),
-            engine.makeConnection("[EqualizerRack1_[Channel2]_Effect1]", "parameter1", function(value) {
-                midi.sendShortMsg(cc, 0x0A, linearize(value));
-            }),
-            // Quick Effect Super Knob
-            engine.makeConnection("[QuickEffectRack1_[Channel1]]", "super1", function(value) {
-                midi.sendShortMsg(cc, 0x0D, value * 127);
-            }),
-            engine.makeConnection("[QuickEffectRack1_[Channel2]]", "super1", function(value) {
-                midi.sendShortMsg(cc, 0x0E, value * 127);
-            }),
-        ];
-
-        // Initialize Twister's LED
-        for (var i = 0; i < MidiFighterTwister.connections.length; i++) {
-            MidiFighterTwister.connections[i].trigger();
+        toggleEffects(_channel, _control, value, _status, group) {
+            if (value === 0) {
+                // Only toggle on press, don't toggle it again on release.
+                return;
+            }
+            switch (group) {
+            case "[Channel1]":
+                this.fx[0].toggle();
+                break;
+            case "[Channel2]":
+                this.fx[1].toggle();
+                break;
+            default:
+                console.log(`invalid group: ${group}`);
+            }
         }
 
-        // Initialize BPM LED
-        midi.sendShortMsg(cc, 0x00, 64)
-        midi.sendShortMsg(cc, 0x03, 64)
-    },
-    shutdown: function() {
-        for (var i = 0; i < MidiFighterTwister.connections.length; i++) {
-            MidiFighterTwister.connections[i].disconnect();
+        toggleDeck(_channel, _control, value, _status, group) {
+            if (value === 0) {
+                // Only toggle on press, don't toggle it again on release.
+                return;
+            }
+            switch (group) {
+            case "[Channel1]":
+                this.leftDeck.toggle();
+                break;
+            case "[Channel2]":
+                this.rightDeck.toggle();
+                break;
+            default:
+                console.log(`invalid group: ${group}`);
+            }
         }
-    },
-    resetSuperKnob: function(channel, control, value, status, group) {
-        engine.setValue(group, "super1", 0.5);
-    },
-}
+    }
+
+    MidiFighterTwister.init = function() {
+        MidiFighterTwister.controller = new Controller();
+    };
+
+    MidiFighterTwister.shutdown = function() {
+        MidiFighterTwister.controller.shutdown();
+    };
+})(MidiFighterTwister || (MidiFighterTwister = {}));
+
+// vim:expandtab:tabstop=4:shiftwidth=4


Reviews:
user: login: JoergAtGithub
body: Please do not replace semantic XML mapping by scripting if it is not technically mandatory. As semantic mapping is machine readable and easy to modify by users.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-27T19:01:52Z
user: login: acolombier
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T22:56:59Z
user: login: SamWhited
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T10:37:58Z
user: login: SamWhited
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:36:44Z
user: login: SamWhited
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:54:22Z
user: login: acolombier
body: Code looks good, no device to test unfortunately! 

I'm planning to merge that over the weekend.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-16T08:51:40Z

Review comments:
diff_hunk: @@ -2,286 +2,439 @@
 <MixxxControllerPreset mixxxVersion="" schemaVersion="1">
   <info>
     <name>DJ TechTools MIDI Fighter Twister</name>
-    <author>tock203</author>
-    <description>for 2 deck use</description>
-    <manual>dj_techtools_midi_figher_twister</manual>
+    <author>tock203, Sam Whited</author>
+    <description>Control 4 decks with the MIDI Fighter Twister.</description>
+    <manual>dj_techtools_midi_fighter_twister</manual>
+    <forums>https://mixxx.discourse.group/t/mapping-the-midifighter-twister/16253</forums>
+    <devices>
+      <product protocol="midi" vendor_id="0x2580" product_id="0x0007"/>
+    </devices>
   </info>
+  <settings>
+    <option variable="vuMeter" type="boolean" label="Use gain knob as vu meter" default="false">
+      <description>
+        Show instantaneous volume on the 11 segment LEDs around the gain knob instead of the position of the knob.
+      </description>
+    </option>
+    <option variable="beatColor" type="enum" label="Beat Indicator Color">
+      <description>
+        Use the tempo knob LED as a beat indicator when set.
+      </description>
+      <value label="Off" default="true">off</value>
+      <value label="Firmware Default">127</value>
+      <value label="Blue">1</value>
+      <value label="Celeste">32</value>
+      <value label="Teal">40</value>
+      <value label="Green">50</value>
+      <value label="Lime">60</value>
+      <value label="Yellow">66</value>
+      <value label="Orange">74</value>
+      <value label="Red">85</value>
+      <value label="Fuscia">100</value>
+      <value label="Purple">108</value>
path: res/controllers/DJ TechTools MIDI Fighter Twister.midi.xml
user: login: acolombier
body: Just a quick note that [`value` now support the `color` attribute](https://github.com/mixxxdj/mixxx/wiki/MIDI-Scripting#additional-attributes-and-elements-for-the-enum-type). Just a nit but it can help enriching the UX 
![image](https://github.com/user-attachments/assets/e8d74a39-6c8e-43d8-a4bf-a409e1ee0fdb)

created_at: 2025-05-12T22:56:59Z
author_association: MEMBER
diff_hunk: @@ -2,286 +2,439 @@
 <MixxxControllerPreset mixxxVersion="" schemaVersion="1">
   <info>
     <name>DJ TechTools MIDI Fighter Twister</name>
-    <author>tock203</author>
-    <description>for 2 deck use</description>
-    <manual>dj_techtools_midi_figher_twister</manual>
+    <author>tock203, Sam Whited</author>
+    <description>Control 4 decks with the MIDI Fighter Twister.</description>
+    <manual>dj_techtools_midi_fighter_twister</manual>
+    <forums>https://mixxx.discourse.group/t/mapping-the-midifighter-twister/16253</forums>
+    <devices>
+      <product protocol="midi" vendor_id="0x2580" product_id="0x0007"/>
+    </devices>
   </info>
+  <settings>
+    <option variable="vuMeter" type="boolean" label="Use gain knob as vu meter" default="false">
+      <description>
+        Show instantaneous volume on the 11 segment LEDs around the gain knob instead of the position of the knob.
+      </description>
+    </option>
+    <option variable="beatColor" type="enum" label="Beat Indicator Color">
+      <description>
+        Use the tempo knob LED as a beat indicator when set.
+      </description>
+      <value label="Off" default="true">off</value>
+      <value label="Firmware Default">127</value>
+      <value label="Blue">1</value>
+      <value label="Celeste">32</value>
+      <value label="Teal">40</value>
+      <value label="Green">50</value>
+      <value label="Lime">60</value>
+      <value label="Yellow">66</value>
+      <value label="Orange">74</value>
+      <value label="Red">85</value>
+      <value label="Fuscia">100</value>
+      <value label="Purple">108</value>
path: res/controllers/DJ TechTools MIDI Fighter Twister.midi.xml
user: login: SamWhited
body: Oh right; is that 2.6 only, and should I be switching these branches over to use 2.6 as a base since that's in beta and presumably going to be released in the near future, or is this in 2.5 now as well?
created_at: 2025-05-13T10:37:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,286 +2,439 @@
 <MixxxControllerPreset mixxxVersion="" schemaVersion="1">
   <info>
     <name>DJ TechTools MIDI Fighter Twister</name>
-    <author>tock203</author>
-    <description>for 2 deck use</description>
-    <manual>dj_techtools_midi_figher_twister</manual>
+    <author>tock203, Sam Whited</author>
+    <description>Control 4 decks with the MIDI Fighter Twister.</description>
+    <manual>dj_techtools_midi_fighter_twister</manual>
+    <forums>https://mixxx.discourse.group/t/mapping-the-midifighter-twister/16253</forums>
+    <devices>
+      <product protocol="midi" vendor_id="0x2580" product_id="0x0007"/>
+    </devices>
   </info>
+  <settings>
+    <option variable="vuMeter" type="boolean" label="Use gain knob as vu meter" default="false">
+      <description>
+        Show instantaneous volume on the 11 segment LEDs around the gain knob instead of the position of the knob.
+      </description>
+    </option>
+    <option variable="beatColor" type="enum" label="Beat Indicator Color">
+      <description>
+        Use the tempo knob LED as a beat indicator when set.
+      </description>
+      <value label="Off" default="true">off</value>
+      <value label="Firmware Default">127</value>
+      <value label="Blue">1</value>
+      <value label="Celeste">32</value>
+      <value label="Teal">40</value>
+      <value label="Green">50</value>
+      <value label="Lime">60</value>
+      <value label="Yellow">66</value>
+      <value label="Orange">74</value>
+      <value label="Red">85</value>
+      <value label="Fuscia">100</value>
+      <value label="Purple">108</value>
path: res/controllers/DJ TechTools MIDI Fighter Twister.midi.xml
user: login: SamWhited
body: ooh, wait, this isn't the color type that was added in 2.6 (I think), this is an attribute for the enum type, got it! Thanks, I'll go add those!
created_at: 2025-05-13T15:36:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,286 +2,439 @@
 <MixxxControllerPreset mixxxVersion="" schemaVersion="1">
   <info>
     <name>DJ TechTools MIDI Fighter Twister</name>
-    <author>tock203</author>
-    <description>for 2 deck use</description>
-    <manual>dj_techtools_midi_figher_twister</manual>
+    <author>tock203, Sam Whited</author>
+    <description>Control 4 decks with the MIDI Fighter Twister.</description>
+    <manual>dj_techtools_midi_fighter_twister</manual>
+    <forums>https://mixxx.discourse.group/t/mapping-the-midifighter-twister/16253</forums>
+    <devices>
+      <product protocol="midi" vendor_id="0x2580" product_id="0x0007"/>
+    </devices>
   </info>
+  <settings>
+    <option variable="vuMeter" type="boolean" label="Use gain knob as vu meter" default="false">
+      <description>
+        Show instantaneous volume on the 11 segment LEDs around the gain knob instead of the position of the knob.
+      </description>
+    </option>
+    <option variable="beatColor" type="enum" label="Beat Indicator Color">
+      <description>
+        Use the tempo knob LED as a beat indicator when set.
+      </description>
+      <value label="Off" default="true">off</value>
+      <value label="Firmware Default">127</value>
+      <value label="Blue">1</value>
+      <value label="Celeste">32</value>
+      <value label="Teal">40</value>
+      <value label="Green">50</value>
+      <value label="Lime">60</value>
+      <value label="Yellow">66</value>
+      <value label="Orange">74</value>
+      <value label="Red">85</value>
+      <value label="Fuscia">100</value>
+      <value label="Purple">108</value>
path: res/controllers/DJ TechTools MIDI Fighter Twister.midi.xml
user: login: SamWhited
body: Added; thanks again for the tip! This is only supported in 2.6, but it's an attribute that will be ignored otherwise so might as well go ahead and put this in here even if it gets merged into 2.5.2 eventually.
created_at: 2025-05-13T15:54:22Z
author_association: CONTRIBUTOR

PR comments:
user: login: SamWhited
created_at: 2025-03-27T19:03:41Z
body: > Please do not replace semantic XML mapping by scripting if it is not technically mandatory. As semantic mapping is machine readable and easy to modify by users.


It is not possible to make this a 4 deck controller (which is done as part of this PR) with only an XML mapping, nor will it be possible to add a full effects control system later, or improve the LED usage, which is what I'd like to do with this controller.

*EDIT:* I've updated the title to make it clearer what this does, as I'm assuming that is what got read that made it look like it was a change just to how the mappings worked.
author_association: CONTRIBUTOR
user: login: SamWhited
created_at: 2025-04-27T22:11:25Z
body: Just rebased and squashed; now that 2.5.1 is out it would be great to get a reviewer on this!
author_association: CONTRIBUTOR
user: login: SamWhited
created_at: 2025-05-18T11:25:29Z
body: Rebased this for merge; no actual changes made.
author_association: CONTRIBUTOR
user: login: daschuer
created_at: 2025-05-25T10:05:27Z
body: Thank you. All green ... I merge it now.  
author_association: MEMBER