params = new HashMap<>();
+ final TomlTable tomlParams = table.getTable("params");
+
+ for (String key : tomlParams.keySet()) {
+ final String paramId = tomlParams.getString(key);
+
+ if (paramId == null || paramId.isEmpty()) {
+ throw new IllegalArgumentException("Parameter ID must not be null or empty");
+ }
+
+ params.put(key, paramId);
+ }
+
+ return new BitwigDeviceSetting(id, params);
+ }
+
+ @Override
+ public Parameter createParameter(Device device, String key)
+ {
+ return device.createSpecificBitwigDevice(id).createParameter(params.get(key));
+ }
+}
diff --git a/src/main/java/io/github/dozius/settings/SpecificDeviceSettings.java b/src/main/java/io/github/dozius/settings/SpecificDeviceSettings.java
new file mode 100644
index 0000000..e6f0929
--- /dev/null
+++ b/src/main/java/io/github/dozius/settings/SpecificDeviceSettings.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.settings;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.tomlj.Toml;
+import org.tomlj.TomlArray;
+import org.tomlj.TomlParseError;
+import org.tomlj.TomlParseResult;
+import org.tomlj.TomlTable;
+
+/**
+ * Represents data loaded from a specific devices settings file.
+ *
+ * This data can be used to programmatically setup specific devices using the specific device
+ * portion of the extension API. Since the API provides no way to query device and param IDs, this
+ * allows for more flexibility than a hardcoded approach to specific device setup.
+ *
+ * The TOML file is required to a have a specific structure.
+ *
+ * A table called "controls". This table can have any number of string array keys. These string
+ * arrays are loaded into the controlMap. This table is optional.
+ *
+ * Three table arrays, "bitwig", "vst3" and "vst2". These represent all the device settings. These
+ * arrays are optional.
+ *
+ * Each table requires an id key that is equal to the device ID taken from Bitwig. For Bitwig and
+ * VST3 devices it is a string, for VST2 it is an integer.
+ *
+ * Each table has a sub table called "params". This table contains any number of keys that are equal
+ * to parameter IDs taken from Bitwig. Bitwig device parameters are strings, VST3 and VST2 device
+ * parameters are integers.
+ *
+ * Example:
+ *
+ *
+ * [controls]
+ * knob1 = ["mix"]
+ * knob2 = ["output_gain", "decay_time"]
+ *
+ * [[bitwig]]
+ * id = "b5b2b08e-730e-4192-be71-f572ceb5069b"
+ * params.mix = "MIX"
+ * params.output_gain = "LEVEL"
+ *
+ * [[vst3]]
+ * id = "5653547665653376616C68616C6C6176"
+ * params.mix = 48
+ * params.decay_time = 50
+ *
+ * [[vst2]]
+ * id = 1315513406
+ * params.mix = 11
+ *
+ *
+ * These table arrays end up as the bitwigDevices, vst3Devices and vst2Devices lists.
+ */
+public class SpecificDeviceSettings
+{
+ private final Map> controlMap;
+ private final List bitwigDevices;
+ private final List vst3Devices;
+ private final List vst2Devices;
+
+ /**
+ * Constructs a SpecificDeviceSettings object from the specified TOML file.
+ *
+ * @param settingsPath The path to the TOML file to parse.
+ */
+ public SpecificDeviceSettings(Path settingsPath)
+ {
+ try {
+ final TomlParseResult toml = Toml.parse(settingsPath);
+
+ if (toml.hasErrors()) {
+ final ArrayList errorMessages = new ArrayList<>();
+
+ for (final TomlParseError error : toml.errors()) {
+ errorMessages.add(error.toString());
+ }
+
+ throw new ParseException(String.join("\n", errorMessages), 0);
+ }
+
+ controlMap = loadControls(toml);
+ bitwigDevices = loadDevices(toml, "bitwig", BitwigDeviceSetting::fromToml);
+ vst3Devices = loadDevices(toml, "vst3", Vst3DeviceSetting::fromToml);
+ vst2Devices = loadDevices(toml, "vst2", Vst2DeviceSetting::fromToml);
+ }
+ catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Constructs a SpecificDeviceSettings object from the specified TOML file.
+ *
+ * @param settingsPath The path to the TOML file to parse as a string.
+ */
+ public SpecificDeviceSettings(String settingsPath)
+ {
+ this(Paths.get(settingsPath));
+ }
+
+ /**
+ * @return Map of controls with parameter keys.
+ */
+ public Map> controlMap()
+ {
+ return controlMap;
+ }
+
+ /**
+ * @return List of Bitwig devices.
+ */
+ public List bitwigDevices()
+ {
+ return bitwigDevices;
+ }
+
+ /**
+ * @return List of VST3 devices.
+ */
+ public List vst3Devices()
+ {
+ return vst3Devices;
+ }
+
+ /**
+ * @return List of VST2 devices.
+ */
+ public List vst2Devices()
+ {
+ return vst2Devices;
+ }
+
+ /**
+ * Loads all the controls and their device parameter key arrays from the controls table.
+ *
+ * @param result The parse result of the the TOML config file.
+ *
+ * @return A set of control keys mapped to lists of device parameter keys.
+ */
+ private Map> loadControls(TomlParseResult result)
+ {
+ final TomlTable controlsTable = result.getTable("controls");
+
+ if (controlsTable == null) {
+ return null;
+ }
+
+ final Map> output = new HashMap<>();
+
+ for (String key : controlsTable.keySet()) {
+ final TomlArray controlParams = controlsTable.getArray(key);
+
+ if (controlParams == null) {
+ continue;
+ }
+
+ Set params = new HashSet<>();
+
+ for (int i = 0; i < controlParams.size(); ++i) {
+ final String param = controlParams.getString(i);
+
+ if (param == null) {
+ continue;
+ }
+
+ params.add(param);
+ }
+
+ output.put(key, params);
+ }
+
+ return output;
+ }
+
+ /**
+ * Loads all the devices of a specific type.
+ *
+ * @param The type of the device ID.
+ * @param The settings type for the device.
+ * @param result The parse result of the TOML config file.
+ * @param key The key of the array of device tables.
+ * @param deviceBuilder A function that will construct the device setting from a TOML table.
+ *
+ * @return A list of valid device settings.
+ */
+ private > List loadDevices(TomlParseResult result,
+ String key,
+ Function deviceBuilder)
+ {
+ final TomlArray devices = result.getArray(key);
+
+ if (devices == null) {
+ return null;
+ }
+
+ final Map settings = new HashMap<>();
+
+ for (int idx = 0; idx < devices.size(); ++idx) {
+ final SettingType device = deviceBuilder.apply(devices.getTable(idx));
+
+ if (settings.containsKey(device.id())) {
+ throw new RuntimeException("Duplicate " + key + " device ID found: " + device.id());
+ }
+
+ settings.put(device.id(), device);
+ }
+
+ return new ArrayList<>(settings.values());
+ }
+}
diff --git a/src/main/java/io/github/dozius/settings/UserColorSettings.java b/src/main/java/io/github/dozius/settings/UserColorSettings.java
new file mode 100644
index 0000000..1a54ea2
--- /dev/null
+++ b/src/main/java/io/github/dozius/settings/UserColorSettings.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.settings;
+
+import java.util.List;
+
+import com.bitwig.extension.controller.api.ControllerHost;
+import com.bitwig.extension.controller.api.DocumentState;
+import com.bitwig.extension.controller.api.RelativeHardwarControlBindable;
+import com.bitwig.extension.controller.api.SettableEnumValue;
+import com.bitwig.extension.controller.api.SettableRangedValue;
+import com.bitwig.extension.controller.api.Setting;
+
+import io.github.dozius.twister.Twister;
+import io.github.dozius.util.MathUtil;
+
+/**
+ * Color settings for the individual RGB lights in each user mappable bank.
+ *
+ * These settings can be accessed in the I/O panel. There is also a helper function to create
+ * bindable targets for a setting so that it can be controlled via hardware.
+ */
+public class UserColorSettings
+{
+ private static final int NUM_USER_BANKS = 3;
+ private static final int NUM_KNOBS_PER_BANK = Twister.Bank.NUM_KNOBS;
+
+ private final List options = List.of("Hide", "2", "3", "4");
+ private final SettableRangedValue[][] settings = new SettableRangedValue[NUM_USER_BANKS][NUM_KNOBS_PER_BANK];
+ private final SettableEnumValue selector;
+
+ /**
+ * Creates all the settings from the document state.
+ *
+ * @param documentState The document state of the host.
+ */
+ public UserColorSettings(DocumentState documentState)
+ {
+ // Setup the bank selector
+ final String[] strings = options.toArray(String[]::new);
+ selector = documentState.getEnumSetting("Bank", "Colors", strings, options.get(0));
+ selector.addValueObserver((value) -> showBank(options.indexOf(value) - 1));
+
+ // Create all the individual settings
+ for (int bank = 0; bank < settings.length; ++bank) {
+ final SettableRangedValue[] settingsBank = settings[bank];
+
+ for (int idx = 0; idx < settingsBank.length; ++idx) {
+ final String label = String.format("Color %02d%" + (bank + 1) + "s", idx + 1, " ");
+ final SettableRangedValue colorSetting = documentState.getNumberSetting(label, "Colors", 0,
+ 125, 1, null, 0);
+ settingsBank[idx] = colorSetting;
+ ((Setting) colorSetting).hide();
+ }
+ }
+ }
+
+ /**
+ * Gets a specific setting.
+ *
+ * @param colorBankIndex Bank index of the desired setting.
+ * @param knobIndex Knob index of the desired setting.
+ *
+ * @return The setting for the given bank and index.
+ */
+ public SettableRangedValue getSetting(int colorBankIndex, int knobIndex)
+ {
+ return settings[colorBankIndex][knobIndex];
+ }
+
+ /**
+ * Creates a target to a color setting that is able to be bound to hardware.
+ *
+ * Despite being a SettableRangedValue, the settings are not compatible targets and this proxy
+ * target must be created instead.
+ *
+ * @param host The controller host.
+ * @param setting The setting to create a target for.
+ *
+ * @return A bindable target to the setting.
+ */
+ public static RelativeHardwarControlBindable createTarget(ControllerHost host,
+ SettableRangedValue setting)
+ {
+ return host.createRelativeHardwareControlAdjustmentTarget((value) -> {
+ final double adjustedValue = MathUtil.clamp(setting.get() + value, 0.0, 1.0);
+ setting.set(adjustedValue);
+ });
+ }
+
+ /** Hides all the settings from the UI panel. */
+ private void hideAll()
+ {
+ for (final SettableRangedValue[] settingsBank : settings) {
+ for (final SettableRangedValue colorSetting : settingsBank) {
+ ((Setting) colorSetting).hide();
+ }
+ }
+ }
+
+ /** Handles bank visibility */
+ private void showBank(int index)
+ {
+ hideAll();
+
+ if (index < 0) {
+ return;
+ }
+
+ for (final SettableRangedValue colorSetting : settings[index]) {
+ ((Setting) colorSetting).show();
+ }
+ }
+}
diff --git a/src/main/java/io/github/dozius/settings/Vst2DeviceSetting.java b/src/main/java/io/github/dozius/settings/Vst2DeviceSetting.java
new file mode 100644
index 0000000..e8f69de
--- /dev/null
+++ b/src/main/java/io/github/dozius/settings/Vst2DeviceSetting.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.settings;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.bitwig.extension.controller.api.Device;
+import com.bitwig.extension.controller.api.Parameter;
+
+import org.tomlj.TomlTable;
+
+/**
+ * VST2 specific device settings.
+ *
+ * Contains an ID as well as any parameter IDs that were set during construction.
+ */
+public class Vst2DeviceSetting extends AbstractDeviceSetting
+{
+ private Vst2DeviceSetting(Integer id, Map params)
+ {
+ super(id, params);
+ }
+
+ /**
+ * Constructs a Vst2DeviceSetting object from a TOML table.
+ *
+ * @param table The TOML table from which to create the object.
+ *
+ * @return A new Vst2DeviceSetting. Throws on errors.
+ */
+ public static Vst2DeviceSetting fromToml(TomlTable table)
+ {
+ final Integer id = Math.toIntExact(table.getLong("id"));
+
+ // All VST2 IDs seem to be positive integers
+ if (id < 0) {
+ throw new IllegalArgumentException("VST2 device ID must not be negative");
+ }
+
+ final Map params = new HashMap<>();
+ final TomlTable tomlParams = table.getTable("params");
+
+ for (String key : tomlParams.keySet()) {
+ final Integer paramId = Math.toIntExact(tomlParams.getLong(key));
+
+ if (paramId < 0) {
+ throw new IllegalArgumentException("Parameter ID must not be negative");
+ }
+
+ params.put(key, paramId);
+ }
+
+ return new Vst2DeviceSetting(id, params);
+ }
+
+ @Override
+ public Parameter createParameter(Device device, String key)
+ {
+ return device.createSpecificVst2Device(id).createParameter(params.get(key));
+ }
+}
diff --git a/src/main/java/io/github/dozius/settings/Vst3DeviceSetting.java b/src/main/java/io/github/dozius/settings/Vst3DeviceSetting.java
new file mode 100644
index 0000000..8f3f9b8
--- /dev/null
+++ b/src/main/java/io/github/dozius/settings/Vst3DeviceSetting.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.settings;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.bitwig.extension.controller.api.Device;
+import com.bitwig.extension.controller.api.Parameter;
+
+import org.tomlj.TomlTable;
+
+/**
+ * VST3 specific device settings.
+ *
+ * Contains an ID as well as any parameter IDs that were set during construction.
+ */
+public class Vst3DeviceSetting extends AbstractDeviceSetting
+{
+ private Vst3DeviceSetting(String id, Map params)
+ {
+ super(id, params);
+ }
+
+ /**
+ * Constructs a Vst3DeviceSetting object from a TOML table.
+ *
+ * @param table The TOML table from which to create the object.
+ *
+ * @return A new Vst3DeviceSetting. Throws on errors.
+ */
+ public static Vst3DeviceSetting fromToml(TomlTable table)
+ {
+ final String id = table.getString("id");
+
+ // All VST3 IDs seem to be a 32 character hex strings
+ if (id.length() != 32) {
+ throw new IllegalArgumentException("VST3 device ID must be 32 characters long");
+ }
+
+ final Map params = new HashMap<>();
+ final TomlTable tomlParams = table.getTable("params");
+
+ for (String key : tomlParams.keySet()) {
+ final Integer paramId = Math.toIntExact(tomlParams.getLong(key));
+
+ if (paramId < 0) {
+ throw new IllegalArgumentException("Parameter ID must not be negative");
+ }
+
+ params.put(key, paramId);
+ }
+
+ return new Vst3DeviceSetting(id, params);
+ }
+
+ @Override
+ public Parameter createParameter(Device device, String key)
+ {
+ return device.createSpecificVst3Device(id).createParameter(params.get(key));
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/KnobMidiInfo.java b/src/main/java/io/github/dozius/twister/KnobMidiInfo.java
new file mode 100644
index 0000000..ccd6fef
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/KnobMidiInfo.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+/**
+ * Contains MIDI information for a Twister knob. This include the encoder, button and both lights.
+ */
+public class KnobMidiInfo
+{
+ public final MidiInfo encoder;
+ public final MidiInfo button;
+ public final LightMidiInfo rgbLight;
+ public final LightMidiInfo ringLight;
+
+ public KnobMidiInfo(MidiInfo encoder, MidiInfo button, LightMidiInfo rgbLight,
+ LightMidiInfo ringLight)
+ {
+ this.encoder = encoder;
+ this.button = button;
+ this.rgbLight = rgbLight;
+ this.ringLight = ringLight;
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/LightMidiInfo.java b/src/main/java/io/github/dozius/twister/LightMidiInfo.java
new file mode 100644
index 0000000..495fdc7
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/LightMidiInfo.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+/** Contains MIDI information for a Twister light. */
+public class LightMidiInfo
+{
+ public final MidiInfo light;
+ public final MidiInfo animation;
+
+ public LightMidiInfo(int channel, int animationChannel, int cc)
+ {
+ this.light = new MidiInfo(channel, cc);
+ this.animation = new MidiInfo(animationChannel, cc);
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/MidiInfo.java b/src/main/java/io/github/dozius/twister/MidiInfo.java
new file mode 100644
index 0000000..3316b4e
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/MidiInfo.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+import com.bitwig.extension.api.util.midi.ShortMidiMessage;
+
+/** Contains basic Twister hardware MIDI information. */
+public class MidiInfo
+{
+ public final int channel;
+ public final int cc;
+ public final int statusByte;
+
+ public MidiInfo(int channel, int cc)
+ {
+ this.channel = channel;
+ this.cc = cc;
+ this.statusByte = ShortMidiMessage.CONTROL_CHANGE + channel;
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/Twister.java b/src/main/java/io/github/dozius/twister/Twister.java
new file mode 100644
index 0000000..ac8c6b1
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/Twister.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+import com.bitwig.extension.api.util.midi.ShortMidiMessage;
+import com.bitwig.extension.controller.api.ControllerHost;
+import com.bitwig.extension.controller.api.HardwareSurface;
+import com.bitwig.extension.controller.api.MidiIn;
+import com.bitwig.extension.controller.api.MidiOut;
+
+import io.github.dozius.TwisterSisterExtension;
+
+/**
+ * Twister hardware.
+ *
+ * All the hardware available on the MIDI Fighter Twister is setup on construction and accessible
+ * through this class.
+ */
+public class Twister
+{
+ /** Twister MIDI channels. Zero indexed. */
+ public static class MidiChannel
+ {
+ public static final int ENCODER = 0;
+ public static final int BUTTON = 1;
+ public static final int RGB_ANIMATION = 2;
+ public static final int SIDE_BUTTON = 3;
+ public static final int SYSTEM = 3;
+ public static final int SHIFT = 4;
+ public static final int RING_ANIMATION = 5;
+ public static final int SEQUENCER = 7;
+ }
+
+ /** A single bank of Twister hardware. */
+ public class Bank
+ {
+ public static final int NUM_KNOBS = 16;
+ public static final int NUM_LEFT_SIDE_BUTTONS = 3;
+ public static final int NUM_RIGHT_SIDE_BUTTONS = 3;
+ public static final int NUM_SIDE_BUTTONS = NUM_LEFT_SIDE_BUTTONS + NUM_RIGHT_SIDE_BUTTONS;
+
+ public final TwisterKnob[] knobs = new TwisterKnob[NUM_KNOBS];
+ public final TwisterButton[] leftSideButtons = new TwisterButton[NUM_LEFT_SIDE_BUTTONS];
+ public final TwisterButton[] rightSideButtons = new TwisterButton[NUM_RIGHT_SIDE_BUTTONS];
+ }
+
+ public static final int NUM_BANKS = 4;
+
+ public final Bank[] banks = new Bank[NUM_BANKS];
+
+ private final MidiOut midiOut;
+ private final HardwareSurface hardwareSurface;
+ private final ControllerHost host;
+
+ private boolean popupEnabled = false;
+
+ /**
+ * Creates a new Twister.
+ *
+ * @param extension The parent Bitwig extension.
+ */
+ public Twister(TwisterSisterExtension extension)
+ {
+ this.midiOut = extension.midiOut;
+ this.hardwareSurface = extension.hardwareSurface;
+ this.host = extension.getHost();
+
+ createHardware(extension);
+ createSequencerInput(extension.midiIn);
+ createBankSwitchListener(extension.midiIn);
+ }
+
+ /**
+ * Sets the active bank to the desired index.
+ *
+ * @param index Desired bank index.
+ */
+ public void setActiveBank(int index)
+ {
+ assert index > 0 && index < NUM_BANKS : "index is invalid";
+ midiOut.sendMidi(ShortMidiMessage.CONTROL_CHANGE + MidiChannel.SYSTEM, index, 127);
+ showBankChangePopup(index);
+ }
+
+ /**
+ * Enables/disables any popup notifications related to Twister activity.
+ *
+ * @param enabled True to enable, false to disable.
+ */
+ public void setPopupEnabled(boolean enabled)
+ {
+ popupEnabled = enabled;
+ }
+
+ /** Turns off all lights on the Twister. */
+ public void lightsOff()
+ {
+ // Helps with "stuck" lights when quitting Bitwig
+ hardwareSurface.updateHardware();
+
+ for (Twister.Bank bank : banks) {
+ for (TwisterKnob knob : bank.knobs) {
+ knob.lightsOff();
+ }
+ }
+ }
+
+ /** Creates all the hardware for the Twister. */
+ private void createHardware(TwisterSisterExtension extension)
+ {
+ final int sideButtonsFirstLeftCC = 8;
+ final int sideButtonsFirstRightCC = sideButtonsFirstLeftCC + Twister.Bank.NUM_LEFT_SIDE_BUTTONS;
+
+ for (int bank = 0; bank < banks.length; ++bank) {
+ banks[bank] = new Bank();
+
+ TwisterKnob[] hardwareKnobs = banks[bank].knobs;
+ TwisterButton[] hardwareLSBs = banks[bank].leftSideButtons;
+ TwisterButton[] hardwareRSBs = banks[bank].rightSideButtons;
+
+ final int knobsBankOffset = Twister.Bank.NUM_KNOBS * bank;
+ final int sideButtonsBankOffset = Twister.Bank.NUM_SIDE_BUTTONS * bank;
+
+ for (int knob = 0; knob < hardwareKnobs.length; ++knob) {
+ final int cc = knob + knobsBankOffset;
+ final MidiInfo encoder = new MidiInfo(MidiChannel.ENCODER, cc);
+ final MidiInfo button = new MidiInfo(MidiChannel.BUTTON, cc);
+ final LightMidiInfo rgbLight = new LightMidiInfo(MidiChannel.BUTTON,
+ MidiChannel.RGB_ANIMATION, cc);
+ final LightMidiInfo ringLight = new LightMidiInfo(MidiChannel.ENCODER,
+ MidiChannel.RING_ANIMATION, cc);
+ final KnobMidiInfo knobInfo = new KnobMidiInfo(encoder, button, rgbLight, ringLight);
+
+ hardwareKnobs[knob] = new TwisterKnob(extension, knobInfo);
+ }
+
+ for (int button = 0; button < hardwareLSBs.length; ++button) {
+ final int cc = sideButtonsFirstLeftCC + button + sideButtonsBankOffset;
+ final MidiInfo midiInfo = new MidiInfo(MidiChannel.SIDE_BUTTON, cc);
+
+ hardwareLSBs[button] = new TwisterButton(extension, midiInfo, "Side");
+ }
+
+ for (int button = 0; button < hardwareRSBs.length; ++button) {
+ final int cc = sideButtonsFirstRightCC + button + sideButtonsBankOffset;
+ final MidiInfo midiInfo = new MidiInfo(MidiChannel.SIDE_BUTTON, cc);
+
+ hardwareRSBs[button] = new TwisterButton(extension, midiInfo, "Side");
+ }
+ }
+ }
+
+ /**
+ * Create a listener for bank switch messages. Shows popup notification on bank switch if popups
+ * are enabled.
+ */
+ private void createBankSwitchListener(MidiIn midiIn)
+ {
+ midiIn.setMidiCallback((status, data1, data2) -> {
+ // Filter everything except bank change messages
+ if (status != 0xB3 || data1 > 3 || data2 != 0x7F) {
+ return;
+ }
+
+ showBankChangePopup(data1);
+ });
+ }
+
+ /** Creates a note input for the sequencer channel on the Twister. */
+ private void createSequencerInput(MidiIn midiIn)
+ {
+ midiIn.createNoteInput("Sequencer", "?7????");
+ }
+
+ /** Shows a bank change popup notification if notifications are enabled. */
+ private void showBankChangePopup(int index)
+ {
+ if (popupEnabled) {
+ host.showPopupNotification("Twister Bank " + (index + 1));
+ }
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/TwisterButton.java b/src/main/java/io/github/dozius/twister/TwisterButton.java
new file mode 100644
index 0000000..c1e39e7
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/TwisterButton.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+
+import javax.swing.Timer;
+
+import com.bitwig.extension.controller.api.ControllerHost;
+import com.bitwig.extension.controller.api.HardwareButton;
+import com.bitwig.extension.controller.api.MidiIn;
+
+import io.github.dozius.TwisterSisterExtension;
+
+/**
+ * A button on the Twister. Can be either a knob button or side button.
+ *
+ * Provides a clicked, double clicked and long press action that can be observed.
+ */
+public class TwisterButton
+{
+ private static final long DOUBLE_CLICK_DURATION = 300 * 1000000; // ms
+ private static final int LONG_PRESS_DURATION = 250; // ms
+ private static final int PRESSED_VALUE = 127;
+ private static final int RELEASED_VALUE = 0;
+
+ private final Set clickedObservers = new HashSet<>();
+ private final Set doubleClickedObservers = new HashSet<>();
+ private final Set longPressedObservers = new HashSet<>();
+ private final Timer longPressTimer;
+ private final HardwareButton button;
+
+ private long lastReleasedTime;
+
+ /**
+ * Creates a new TwisterButton.
+ *
+ * @param extension The parent Bitwig extension.
+ * @param midiInfo MIDI info for the button.
+ * @param idPrefix A prefix string to use when creating the hardware object ID.
+ */
+ public TwisterButton(TwisterSisterExtension extension, MidiInfo midiInfo, String idPrefix)
+ {
+ assert !idPrefix.isEmpty() : "ID prefix is empty";
+
+ final MidiIn midiIn = extension.midiIn;
+ final ControllerHost host = extension.getHost();
+ final int channel = midiInfo.channel;
+ final int cc = midiInfo.cc;
+
+ button = extension.hardwareSurface.createHardwareButton(idPrefix + " Button " + midiInfo.cc);
+
+ button.pressedAction()
+ .setActionMatcher(midiIn.createCCActionMatcher(channel, cc, PRESSED_VALUE));
+
+ button.releasedAction()
+ .setActionMatcher(midiIn.createCCActionMatcher(channel, cc, RELEASED_VALUE));
+
+ button.pressedAction()
+ .setBinding(host.createAction(this::handlePressed, () -> "Handle button pressed"));
+
+ button.releasedAction()
+ .setBinding(host.createAction(this::handleReleased, () -> "Handle button released"));
+
+ longPressTimer = new Timer(LONG_PRESS_DURATION, new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent evt)
+ {
+ notifyLongPressedObservers();
+ }
+ });
+
+ longPressTimer.setRepeats(false);
+ }
+
+ /**
+ * Sets an observer of the double clicked action. This will then be the only observer.
+ *
+ * A convenience function that clears and then adds the observer.
+ *
+ * @param observer Observer to add.
+ */
+ public void setDoubleClickedObserver(Runnable observer)
+ {
+ doubleClickedObservers.clear();
+ doubleClickedObservers.add(observer);
+ }
+
+ /**
+ * Adds an observer of the double clicked action.
+ *
+ * @param observer Observer to add.
+ *
+ * @return True if the observer was already added.
+ */
+ public boolean addDoubleClickedObserver(Runnable observer)
+ {
+ return doubleClickedObservers.add(observer);
+ }
+
+ /** Clears all observers of the double clicked action. */
+ public void clearDoubleClickedObservers()
+ {
+ doubleClickedObservers.clear();
+ }
+
+ /**
+ * Sets an observer of the clicked action. This will then be the only observer.
+ *
+ * A convenience function that clears and then adds the observer.
+ *
+ * @param observer Observer to add.
+ */
+ public boolean setClickedObserver(Runnable observer)
+ {
+ clickedObservers.clear();
+ return clickedObservers.add(observer);
+ }
+
+ /**
+ * Adds an observer of the clicked action.
+ *
+ * @param observer Observer to add.
+ *
+ * @return True if the observer was already added.
+ */
+ public boolean addClickedObserver(Runnable observer)
+ {
+ return clickedObservers.add(observer);
+ }
+
+ /** Clears all observers of the clicked action. */
+ public void clearClickedObservers()
+ {
+ clickedObservers.clear();
+ }
+
+ /**
+ * Sets an observer of the long pressed action. This will then be the only observer.
+ *
+ * A convenience function that clears and then adds the observer.
+ *
+ * @param observer Observer to add.
+ */
+ public boolean setLongPressedObserver(Runnable observer)
+ {
+ longPressedObservers.clear();
+ return longPressedObservers.add(observer);
+ }
+
+ /**
+ * Adds an observer of the long pressed action.
+ *
+ * @param observer Observer to add.
+ *
+ * @return True if the observer was already added.
+ */
+ public boolean addLongPressedObserver(Runnable observer)
+ {
+ return longPressedObservers.add(observer);
+ }
+
+ /** Clears all observers of the long pressed action. */
+ public void clearLongPressedObserver()
+ {
+ longPressedObservers.clear();
+ }
+
+ /** Internal handler of the hardware pressed action. */
+ private void handlePressed()
+ {
+ longPressTimer.start();
+ }
+
+ /** Internal handler of the hardware released action. */
+ private void handleReleased()
+ {
+ if (longPressTimer.isRunning()) {
+ longPressTimer.stop();
+ notifyClickedObservers();
+
+ long now = System.nanoTime();
+
+ if ((now - lastReleasedTime) < DOUBLE_CLICK_DURATION) {
+ notifyDoubleClickedObservers();
+ }
+
+ lastReleasedTime = now;
+ }
+ }
+
+ private void notifyClickedObservers()
+ {
+ for (Runnable observer : clickedObservers) {
+ observer.run();
+ }
+ }
+
+ private void notifyDoubleClickedObservers()
+ {
+ for (Runnable observer : doubleClickedObservers) {
+ observer.run();
+ }
+ }
+
+ private void notifyLongPressedObservers()
+ {
+ for (Runnable observer : longPressedObservers) {
+ observer.run();
+ }
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/TwisterColors.java b/src/main/java/io/github/dozius/twister/TwisterColors.java
new file mode 100644
index 0000000..6b76f8e
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/TwisterColors.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import com.bitwig.extension.api.Color;
+
+/**
+ * Colors from the Twister source code.
+ *
+ * Index corresponds to MIDI values 0 to 127.
+ *
+ * Note that MIDI values 0 and 127 are special values and won't set the device to the color in the
+ * list. Value 0 resets the color override and changes back to the "off color" that was set in the
+ * MIDI Fighter Utility. Value 127 sets the color to the "on color" that was set in the MIDI Fighter
+ * Utility.
+ *
+ * The manual states that 126 should be an override color, but this not the case due to a bug in the
+ * Twister software. https://github.com/DJ-TechTools/Midi_Fighter_Twister_Open_Source/issues/9
+ */
+public class TwisterColors
+{
+ // All 128 colors from the Twister source code
+ public static final List ALL = Collections.unmodifiableList(colorList());
+
+ // Colors settable on the Twister that match the Bitwig device remote parameter colors
+ public static final List BITWIG_PARAMETERS = Collections.unmodifiableList(parameterColorList());
+
+ /** Generates the list of all Twister colors. */
+ private static List colorList()
+ {
+ return Arrays.asList(Color.fromRGB255(0, 0, 0), // 0
+ Color.fromRGB255(0, 0, 255), // 1 - Blue
+ Color.fromRGB255(0, 21, 255), // 2 - Blue (Green Rising)
+ Color.fromRGB255(0, 34, 255), //
+ Color.fromRGB255(0, 46, 255), //
+ Color.fromRGB255(0, 59, 255), //
+ Color.fromRGB255(0, 68, 255), //
+ Color.fromRGB255(0, 80, 255), //
+ Color.fromRGB255(0, 93, 255), //
+ Color.fromRGB255(0, 106, 255), //
+ Color.fromRGB255(0, 119, 255), //
+ Color.fromRGB255(0, 127, 255), //
+ Color.fromRGB255(0, 140, 255), //
+ Color.fromRGB255(0, 153, 255), //
+ Color.fromRGB255(0, 165, 255), //
+ Color.fromRGB255(0, 178, 255), //
+ Color.fromRGB255(0, 191, 255), //
+ Color.fromRGB255(0, 199, 255), //
+ Color.fromRGB255(0, 212, 255), //
+ Color.fromRGB255(0, 225, 255), //
+ Color.fromRGB255(0, 238, 255), //
+ Color.fromRGB255(0, 250, 255), // 21 - End of Blue's Reign
+ Color.fromRGB255(0, 255, 250), // 22 - Green (Blue Fading)
+ Color.fromRGB255(0, 255, 237), //
+ Color.fromRGB255(0, 255, 225), //
+ Color.fromRGB255(0, 255, 212), //
+ Color.fromRGB255(0, 255, 199), //
+ Color.fromRGB255(0, 255, 191), //
+ Color.fromRGB255(0, 255, 178), //
+ Color.fromRGB255(0, 255, 165), //
+ Color.fromRGB255(0, 255, 153), //
+ Color.fromRGB255(0, 255, 140), //
+ Color.fromRGB255(0, 255, 127), //
+ Color.fromRGB255(0, 255, 119), //
+ Color.fromRGB255(0, 255, 106), //
+ Color.fromRGB255(0, 255, 93), //
+ Color.fromRGB255(0, 255, 80), //
+ Color.fromRGB255(0, 255, 67), //
+ Color.fromRGB255(0, 255, 59), //
+ Color.fromRGB255(0, 255, 46), //
+ Color.fromRGB255(0, 255, 33), //
+ Color.fromRGB255(0, 255, 21), //
+ Color.fromRGB255(0, 255, 8), //
+ Color.fromRGB255(0, 255, 0), // 43 - Green
+ Color.fromRGB255(12, 255, 0), // 44 - Green/Red Rising
+ Color.fromRGB255(25, 255, 0), //
+ Color.fromRGB255(38, 255, 0), //
+ Color.fromRGB255(51, 255, 0), //
+ Color.fromRGB255(63, 255, 0), //
+ Color.fromRGB255(72, 255, 0), //
+ Color.fromRGB255(84, 255, 0), //
+ Color.fromRGB255(97, 255, 0), //
+ Color.fromRGB255(110, 255, 0), //
+ Color.fromRGB255(123, 255, 0), //
+ Color.fromRGB255(131, 255, 0), //
+ Color.fromRGB255(144, 255, 0), //
+ Color.fromRGB255(157, 255, 0), //
+ Color.fromRGB255(170, 255, 0), //
+ Color.fromRGB255(182, 255, 0), //
+ Color.fromRGB255(191, 255, 0), //
+ Color.fromRGB255(203, 255, 0), //
+ Color.fromRGB255(216, 255, 0), //
+ Color.fromRGB255(229, 255, 0), //
+ Color.fromRGB255(242, 255, 0), //
+ Color.fromRGB255(255, 255, 0), // 64 - Green + Red (Yellow)
+ Color.fromRGB255(255, 246, 0), // 65 - Red, Green Fading
+ Color.fromRGB255(255, 233, 0), //
+ Color.fromRGB255(255, 220, 0), //
+ Color.fromRGB255(255, 208, 0), //
+ Color.fromRGB255(255, 195, 0), //
+ Color.fromRGB255(255, 187, 0), //
+ Color.fromRGB255(255, 174, 0), //
+ Color.fromRGB255(255, 161, 0), //
+ Color.fromRGB255(255, 148, 0), //
+ Color.fromRGB255(255, 135, 0), //
+ Color.fromRGB255(255, 127, 0), //
+ Color.fromRGB255(255, 114, 0), //
+ Color.fromRGB255(255, 102, 0), //
+ Color.fromRGB255(255, 89, 0), //
+ Color.fromRGB255(255, 76, 0), //
+ Color.fromRGB255(255, 63, 0), //
+ Color.fromRGB255(255, 55, 0), //
+ Color.fromRGB255(255, 42, 0), //
+ Color.fromRGB255(255, 29, 0), //
+ Color.fromRGB255(255, 16, 0), //
+ Color.fromRGB255(255, 4, 0), // 85 - End Red/Green Fading
+ Color.fromRGB255(255, 0, 4), // 86 - Red/ Blue Rising
+ Color.fromRGB255(255, 0, 16), //
+ Color.fromRGB255(255, 0, 29), //
+ Color.fromRGB255(255, 0, 42), //
+ Color.fromRGB255(255, 0, 55), //
+ Color.fromRGB255(255, 0, 63), //
+ Color.fromRGB255(255, 0, 76), //
+ Color.fromRGB255(255, 0, 89), //
+ Color.fromRGB255(255, 0, 102), //
+ Color.fromRGB255(255, 0, 114), //
+ Color.fromRGB255(255, 0, 127), //
+ Color.fromRGB255(255, 0, 135), //
+ Color.fromRGB255(255, 0, 148), //
+ Color.fromRGB255(255, 0, 161), //
+ Color.fromRGB255(255, 0, 174), //
+ Color.fromRGB255(255, 0, 186), //
+ Color.fromRGB255(255, 0, 195), //
+ Color.fromRGB255(255, 0, 208), //
+ Color.fromRGB255(255, 0, 221), //
+ Color.fromRGB255(255, 0, 233), //
+ Color.fromRGB255(255, 0, 246), //
+ Color.fromRGB255(255, 0, 255), // 107 - Blue + Red
+ Color.fromRGB255(242, 0, 255), // 108 - Blue/ Red Fading
+ Color.fromRGB255(229, 0, 255), //
+ Color.fromRGB255(216, 0, 255), //
+ Color.fromRGB255(204, 0, 255), //
+ Color.fromRGB255(191, 0, 255), //
+ Color.fromRGB255(182, 0, 255), //
+ Color.fromRGB255(169, 0, 255), //
+ Color.fromRGB255(157, 0, 255), //
+ Color.fromRGB255(144, 0, 255), //
+ Color.fromRGB255(131, 0, 255), //
+ Color.fromRGB255(123, 0, 255), //
+ Color.fromRGB255(110, 0, 255), //
+ Color.fromRGB255(97, 0, 255), //
+ Color.fromRGB255(85, 0, 255), //
+ Color.fromRGB255(72, 0, 255), //
+ Color.fromRGB255(63, 0, 255), //
+ Color.fromRGB255(50, 0, 255), //
+ Color.fromRGB255(38, 0, 255), //
+ Color.fromRGB255(25, 0, 255), // 126 - Blue-ish
+ Color.fromRGB255(240, 240, 225) // 127 - White ?
+ );
+ }
+
+ /** Generates the list of Twister colors that match Bitwig parameters. */
+ private static List parameterColorList()
+ {
+ // Tweaked by eye for closest match to Bitwig parameter colors
+ return Arrays.asList(ALL.get(86), // RGB(244, 27, 62)
+ ALL.get(70), // RGB(255, 127, 23)
+ ALL.get(64), // RGB(252, 235, 35)
+ ALL.get(51), // RGB(91, 197, 21)
+ ALL.get(37), // RGB(101, 206, 146)
+ ALL.get(14), // RGB(92, 168, 238)
+ ALL.get(111), // RGB(195, 110, 255)
+ ALL.get(97)); // RGB(255, 84, 176)
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/TwisterKnob.java b/src/main/java/io/github/dozius/twister/TwisterKnob.java
new file mode 100644
index 0000000..9f2d601
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/TwisterKnob.java
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+import com.bitwig.extension.api.Color;
+import com.bitwig.extension.controller.api.RelativeHardwareKnob;
+import com.bitwig.extension.controller.api.DoubleValue;
+import com.bitwig.extension.controller.api.HardwareBindable;
+import com.bitwig.extension.controller.api.RelativeHardwareControlBinding;
+
+import io.github.dozius.TwisterSisterExtension;
+
+/** A twister knob, including encoder, shift encoder, button and lights. */
+public class TwisterKnob
+{
+ private static final int FULL_ROTATION = 127;
+
+ private final RelativeHardwareKnob knob;
+ private final TwisterButton button;
+ private final TwisterRGBLight rgbLight;
+ private final TwisterRingLight ringLight;
+ private final RelativeHardwareKnob shiftKnob;
+ private final TwisterRingLight shiftRingLight;
+
+ private boolean isFineSensitivity = false;
+ private double fineSensitivity = 0.25;
+ private double sensitivity = 1.0;
+
+ /**
+ * Creates a new TwisterKnob.
+ *
+ * @param extension The parent Bitwig extension.
+ * @param midiInfo MIDI info for the knob.
+ */
+ public TwisterKnob(TwisterSisterExtension extension, KnobMidiInfo midiInfo)
+ {
+ this(extension, midiInfo, Color.nullColor());
+ }
+
+ /**
+ * Creates a new TwisterKnob and sets the RGB light color.
+ *
+ * @param extension The parent Bitwig extension.
+ * @param midiInfo MIDI info for the knob.
+ * @param color Desired light color.
+ */
+ public TwisterKnob(TwisterSisterExtension extension, KnobMidiInfo midiInfo, Color color)
+ {
+ final int cc = midiInfo.encoder.cc;
+ final int channel = midiInfo.encoder.channel;
+
+ knob = extension.hardwareSurface.createRelativeHardwareKnob("Knob " + cc);
+ knob.setAdjustValueMatcher(extension.midiIn.createRelativeBinOffsetCCValueMatcher(channel, cc,
+ FULL_ROTATION));
+
+ button = new TwisterButton(extension, midiInfo.button, "Knob");
+ rgbLight = new TwisterRGBLight(extension, midiInfo.rgbLight, color);
+ ringLight = new TwisterRingLight(extension, midiInfo.ringLight);
+
+ final int shiftChannel = Twister.MidiChannel.SHIFT;
+
+ shiftKnob = extension.hardwareSurface.createRelativeHardwareKnob("Shift Knob " + cc);
+ shiftKnob.setAdjustValueMatcher(extension.midiIn.createRelativeBinOffsetCCValueMatcher(shiftChannel,
+ cc,
+ FULL_ROTATION));
+ shiftRingLight = new TwisterRingLight(extension,
+ new LightMidiInfo(shiftChannel,
+ midiInfo.ringLight.animation.channel,
+ cc));
+ }
+
+ /** The associated button for this knob. */
+ public TwisterButton button()
+ {
+ return button;
+ }
+
+ /** The associated RGB light for this knob. */
+ public TwisterRGBLight rgbLight()
+ {
+ return rgbLight;
+ }
+
+ /** The associated ring light for this knob. */
+ public TwisterRingLight ringLight()
+ {
+ return ringLight;
+ }
+
+ /** The associated shift ring light for this knob. */
+ public TwisterRingLight shiftRingLight()
+ {
+ return shiftRingLight;
+ }
+
+ /** The value of the target that this knob has been bound to (0-1). */
+ public DoubleValue targetValue()
+ {
+ return knob.targetValue();
+ }
+
+ /** The value of the target that this shift knob has been bound to (0-1). */
+ public DoubleValue shiftTargetValue()
+ {
+ return shiftKnob.targetValue();
+ }
+
+ /**
+ * Sets the regular sensitivity factor for the knob. If regular sensitivity is active it is
+ * applied immediately.
+ *
+ * @param factor The sensitivity factor to apply.
+ */
+ public void setSensitivity(double factor)
+ {
+ sensitivity = factor;
+
+ if (!isFineSensitivity) {
+ knob.setSensitivity(sensitivity);
+ }
+ }
+
+ /**
+ * Sets the fine sensitivity factor for the knob. If fine sensitivity is active it is applied
+ * immediately.
+ *
+ * @param factor The sensitivity factor to apply.
+ */
+ public void setFineSensitivity(double factor)
+ {
+ fineSensitivity = factor;
+
+ if (isFineSensitivity) {
+ knob.setSensitivity(fineSensitivity);
+ }
+ }
+
+ /** Toggles between regular and fine sensitivity. */
+ public void toggleSensitivity()
+ {
+ isFineSensitivity = !isFineSensitivity;
+ knob.setSensitivity(isFineSensitivity ? fineSensitivity : sensitivity);
+ }
+
+ /**
+ * Binds the knob to the supplied target.
+ *
+ * @param target Target to bind.
+ *
+ * @return The created binding.
+ */
+ public RelativeHardwareControlBinding setBinding(HardwareBindable target)
+ {
+ return knob.setBinding(target);
+ }
+
+ /**
+ * Binds the shift knob to the supplied target.
+ *
+ * @param target Target to bind.
+ *
+ * @return The created binding.
+ */
+ public RelativeHardwareControlBinding setShiftBinding(HardwareBindable target)
+ {
+ return shiftKnob.setBinding(target);
+ }
+
+ /** Resets all animations and turns off all lights. */
+ public void lightsOff()
+ {
+ ringLight.lightOff();
+ shiftRingLight.lightOff();
+ rgbLight.lightOff();
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/TwisterLight.java b/src/main/java/io/github/dozius/twister/TwisterLight.java
new file mode 100644
index 0000000..43057de
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/TwisterLight.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+import java.util.EnumMap;
+
+import com.bitwig.extension.controller.api.MidiOut;
+
+import io.github.dozius.TwisterSisterExtension;
+import io.github.dozius.util.MathUtil;
+
+/** Twister light base class. */
+public abstract class TwisterLight
+{
+ /** The available animation states of the light. */
+ public static enum AnimationState
+ {
+ OFF, // animation off
+ STROBE_8_1, // 8/1
+ STROBE_4_1, // 4/1
+ STROBE_2_1, // 2/1
+ STROBE_1_1, // 1/1
+ STROBE_1_2, // 1/2
+ STROBE_1_4, // 1/4
+ STROBE_1_8, // 1/8
+ STROBE_1_16, // 1/16
+ PULSE_8_1, // 8/1
+ PULSE_4_1, // 4/1
+ PULSE_2_1, // 2/1
+ PULSE_1_1, // 1/1
+ PULSE_1_2, // 1/2
+ PULSE_1_4, // 1/4
+ PULSE_1_8, // 1/8
+ PULSE_1_16, // 1/16
+ RAINBOW;
+ }
+
+ protected final MidiOut midiOut;
+
+ private final MidiInfo midiInfo;
+ private final EnumMap animationMap;
+ private final int brightnessStartValue;
+
+ /**
+ * Creates a new TwisterLight.
+ *
+ * @param extension The parent Bitwig extension.
+ * @param animationMidiInfo MIDI information for animations.
+ * @param animationStartValue Animation range starting value.
+ * @param brightnessStartValue Brightness range starting value.
+ */
+ public TwisterLight(TwisterSisterExtension extension, MidiInfo animationMidiInfo,
+ int animationStartValue, int brightnessStartValue)
+ {
+ this.midiOut = extension.midiOut;
+ this.midiInfo = animationMidiInfo;
+ this.animationMap = createAnimationMap(animationStartValue);
+ this.brightnessStartValue = brightnessStartValue;
+ }
+
+ /**
+ * Sets the desired animation state.
+ *
+ * @param state Animation state.
+ */
+ public void setAnimationState(AnimationState state)
+ {
+ assert animationMap.containsKey(state) : "Invalid state";
+ sendAnimation(animationMap.get(state));
+ }
+
+ /**
+ * Overrides the brightness setting with a new brightness value.
+ *
+ * @param brightness The desired brightness.
+ */
+ public void overrideBrightness(double brightness)
+ {
+ final double value = MathUtil.clamp(brightness, 0.0, 1.0);
+ sendAnimation((int) ((value * 30.0) + brightnessStartValue));
+ }
+
+ /** Resets the brightness override. */
+ public void resetBrightness()
+ {
+ sendAnimation(0);
+ }
+
+ /** Turns the light off and resets all animations. */
+ public abstract void lightOff();
+
+ /**
+ * Generates the animation value map based on the start value.
+ *
+ * The ring light and RGB light have different starting MIDI values for the animations. By
+ * providing the starting offset value, the sub class will create the correct value map for
+ * itself.
+ *
+ * @param startValue Starting value of the animation range.
+ *
+ * @return A populated animation value map.
+ */
+ private static EnumMap createAnimationMap(int startValue)
+ {
+ EnumMap map = new EnumMap<>(AnimationState.class);
+
+ map.put(AnimationState.OFF, 0);
+ map.put(AnimationState.RAINBOW, 127);
+
+ int i = startValue;
+ for (AnimationState state : AnimationState.values()) {
+ if (state == AnimationState.OFF || state == AnimationState.RAINBOW) {
+ continue;
+ }
+
+ map.put(state, i);
+ ++i;
+ }
+
+ return map;
+ }
+
+ /**
+ * Sends the raw animation MIDI value to the device.
+ *
+ * @param value The raw MIDI value.
+ */
+ private void sendAnimation(int value)
+ {
+ midiOut.sendMidi(midiInfo.statusByte, midiInfo.cc, value);
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/TwisterRGBLight.java b/src/main/java/io/github/dozius/twister/TwisterRGBLight.java
new file mode 100644
index 0000000..b41e68d
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/TwisterRGBLight.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+import com.bitwig.extension.api.Color;
+import com.bitwig.extension.controller.api.HardwareLightVisualState;
+import com.bitwig.extension.controller.api.InternalHardwareLightState;
+import com.bitwig.extension.controller.api.MidiOut;
+import com.bitwig.extension.controller.api.MultiStateHardwareLight;
+
+import io.github.dozius.TwisterSisterExtension;
+
+/** The RGB light on a twister knob. */
+public class TwisterRGBLight extends TwisterLight
+{
+ private static final int ANIMATION_START_VALUE = 1;
+ private static final int BRIGHTNESS_START_VALUE = 17;
+
+ private final MultiStateHardwareLight light;
+ private final MidiInfo midiInfo;
+
+ /**
+ * Creates a new TwisterRGBLight.
+ *
+ * @param extension The parent Bitwig extension.
+ * @param lightMidiInfo MIDI information for the light.
+ */
+ public TwisterRGBLight(TwisterSisterExtension extension, LightMidiInfo lightMidiInfo)
+ {
+ this(extension, lightMidiInfo, Color.nullColor());
+ }
+
+ /**
+ * Creates a new TwisterRGBLight and sets the color.
+ *
+ * @param extension The parent Bitwig extension.
+ * @param lightMidiInfo MIDI information for the light.
+ * @param color Color to set the light.
+ */
+ public TwisterRGBLight(TwisterSisterExtension extension, LightMidiInfo lightMidiInfo, Color color)
+ {
+ super(extension, lightMidiInfo.animation, ANIMATION_START_VALUE, BRIGHTNESS_START_VALUE);
+
+ midiInfo = lightMidiInfo.light;
+
+ light = extension.hardwareSurface.createMultiStateHardwareLight("RGB Light " + midiInfo.cc);
+
+ light.setColorToStateFunction(col -> new LightState(col));
+ light.state().onUpdateHardware(new LightStateSender(midiOut, midiInfo));
+ light.setColor(color);
+ }
+
+ /**
+ * Sets the color of the light.
+ *
+ * @param color Desired color.
+ */
+ public void setColor(Color color)
+ {
+ light.setColor(color);
+ }
+
+ /**
+ * Sets the color of the light using a raw MIDI value.
+ *
+ * @param value Desired color as a MIDI value.
+ */
+ public void setRawValue(int value)
+ {
+ light.setColor(TwisterColors.ALL.get(value));
+ }
+
+ /**
+ * Sets the color supplier for the light.
+ *
+ * @param colorSupplier Color supplier for the light.
+ */
+ public void setColorSupplier(Supplier colorSupplier)
+ {
+ light.setColorSupplier(colorSupplier);
+ }
+
+ @Override
+ public void lightOff()
+ {
+ setAnimationState(AnimationState.OFF);
+
+ light.setColor(Color.blackColor());
+
+ // Force MIDI to be sent immediately
+ midiOut.sendMidi(midiInfo.statusByte, midiInfo.cc, 0);
+ }
+
+ /** Handler of internal light state. */
+ private class LightState extends InternalHardwareLightState
+ {
+ private int colorIndex = 0;
+ private Color color = Color.nullColor();
+
+ /**
+ * Creates a LightState with the desired color.
+ *
+ * @param color Desired color.
+ */
+ public LightState(Color color)
+ {
+ colorToState(color);
+ }
+
+ @Override
+ public HardwareLightVisualState getVisualState()
+ {
+ return HardwareLightVisualState.createForColor(color);
+ }
+
+ @Override
+ public boolean equals(final Object obj)
+ {
+ if (this == obj) {
+ return true;
+ }
+
+ if (obj == null) {
+ return false;
+ }
+
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+
+ return colorIndex == ((LightState) obj).getColorIndex();
+ }
+
+ /**
+ * Converts a color to the the nearest representable color of the twister.
+ *
+ * @param color Desired color.
+ */
+ public void colorToState(Color color)
+ {
+ // Find if an exact match exists and use this MIDI value if it does
+ int existingIndex = TwisterColors.ALL.indexOf(color);
+
+ if (existingIndex >= 0) {
+ colorIndex = existingIndex;
+ this.color = color;
+ return;
+ }
+
+ // No exact match found, proceed with approximation
+ final int r = color.getRed255();
+ final int g = color.getGreen255();
+ final int b = color.getBlue255();
+
+ // hue = 0, saturation = 1, brightness = 2
+ final float[] hsb = java.awt.Color.RGBtoHSB(r, g, b, null);
+ final float hue = hsb[0];
+ final float saturation = hsb[1];
+
+ /*
+ * 0 turns off the color override and returns to the inactive color set via sysex. Both 126 &
+ * 127 enable override but set the color to the active color set via sysex. Seems that the
+ * inclusion of 126 for this behaviour is a bug.
+ *
+ * ref: process_sw_rgb_update() in encoders.c
+ */
+ if (saturation > 0.0) {
+ final double baseSaturation = 2.0 / 3.0; // RGB 0000FF
+ colorIndex = Math.min(Math.floorMod((int) (125 * (baseSaturation - hue) + 1), 126), 125);
+ this.color = color;
+ }
+ else {
+ colorIndex = 0; // Desaturated colors turn off LED
+ this.color = Color.blackColor();
+ }
+ }
+
+ /** @return Twister color index of the current color state. */
+ public int getColorIndex()
+ {
+ return colorIndex;
+ }
+ }
+
+ /** Consumer that sends the light state to the twister. */
+ private class LightStateSender implements Consumer
+ {
+ private final MidiOut midiOut;
+ private final MidiInfo midiInfo;
+
+ /**
+ * Creates a LightStateSender.
+ *
+ * @param midiOut MIDI output port to use.
+ * @param midiInfo MIDI info for the light.
+ */
+ protected LightStateSender(final MidiOut midiOut, final MidiInfo midiInfo)
+ {
+ super();
+ this.midiOut = midiOut;
+ this.midiInfo = midiInfo;
+ }
+
+ @Override
+ public void accept(final LightState state)
+ {
+ if (state == null) {
+ return;
+ }
+
+ midiOut.sendMidi(midiInfo.statusByte, midiInfo.cc, state.getColorIndex());
+ }
+ }
+}
diff --git a/src/main/java/io/github/dozius/twister/TwisterRingLight.java b/src/main/java/io/github/dozius/twister/TwisterRingLight.java
new file mode 100644
index 0000000..59821f1
--- /dev/null
+++ b/src/main/java/io/github/dozius/twister/TwisterRingLight.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.twister;
+
+import com.bitwig.extension.controller.api.DoubleValue;
+import com.bitwig.extension.controller.api.SettableRangedValue;
+
+import io.github.dozius.TwisterSisterExtension;
+import io.github.dozius.util.CursorNormalizedValue;
+import io.github.dozius.util.MathUtil;
+
+/** The ring light on a Twister knob. */
+public class TwisterRingLight extends TwisterLight
+{
+ private static final int ANIMATION_START_VALUE = 49;
+ private static final int BRIGHTNESS_START_VALUE = 65;
+
+ private final MidiInfo midiInfo;
+
+ /**
+ * Creates a new TwisterRingLight.
+ *
+ * @param extension The parent bitwig extension.
+ * @param lightMidiInfo Midi information for the light.
+ */
+ public TwisterRingLight(TwisterSisterExtension extension, LightMidiInfo lightMidiInfo)
+ {
+ super(extension, lightMidiInfo.animation, ANIMATION_START_VALUE, BRIGHTNESS_START_VALUE);
+ this.midiInfo = lightMidiInfo.light;
+ }
+
+ /**
+ * Sets the ring value using raw MIDI data.
+ *
+ * @param value The desired ring value in MIDI.
+ */
+ public void setRawValue(int value)
+ {
+ midiOut.sendMidi(midiInfo.statusByte, midiInfo.cc, MathUtil.clamp(value, 0, 127));
+ }
+
+ /**
+ * Sets the ring value using a normalized 0-1 range.
+ *
+ * @param value The desired ring value in normalized range.
+ */
+ public void setValue(double value)
+ {
+ setRawValue((int) (value * 127.0));
+ }
+
+ /**
+ * A special normalized 0-1 range value for ring lights being used as cursors in "dot" mode.
+ *
+ * Intended to be used with the value from a CursorNormalizedValue wrapper.
+ *
+ * Values in the 0-1 range will show the dot, even when at 0. Negative values hide the dot.
+ *
+ * @param value The cursor value to set.
+ */
+ public void setCursorValue(double value)
+ {
+ if (value < 0) {
+ setRawValue(0);
+ return;
+ }
+
+ setRawValue((int) ((value * 126.0) + 1.0));
+ }
+
+ /**
+ * Makes this light an observer of the passed in value.
+ *
+ * @param value The value to observe.
+ */
+ public void observeValue(SettableRangedValue value)
+ {
+ value.markInterested();
+ value.addValueObserver(128, this::setRawValue);
+ }
+
+ /**
+ * Makes this light an observer of the passed in value.
+ *
+ * @param value The value to observe.
+ */
+ public void observeValue(DoubleValue value)
+ {
+ value.markInterested();
+ value.addValueObserver(this::setValue);
+ }
+
+ /**
+ * Makes this light an observer of the passed in wrapper.
+ *
+ * @param wrapper The wrapper to observe.
+ */
+ public void observeValue(CursorNormalizedValue wrapper)
+ {
+ wrapper.addValueObserver(this::setCursorValue);
+ }
+
+ @Override
+ public void lightOff()
+ {
+ setAnimationState(AnimationState.OFF);
+ setRawValue(0);
+ }
+}
diff --git a/src/main/java/io/github/dozius/util/CursorNormalizedValue.java b/src/main/java/io/github/dozius/util/CursorNormalizedValue.java
new file mode 100644
index 0000000..f77feb6
--- /dev/null
+++ b/src/main/java/io/github/dozius/util/CursorNormalizedValue.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.util;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import com.bitwig.extension.callback.DoubleValueChangedCallback;
+import com.bitwig.extension.controller.api.CursorDevice;
+import com.bitwig.extension.controller.api.CursorRemoteControlsPage;
+import com.bitwig.extension.controller.api.CursorTrack;
+import com.bitwig.extension.controller.api.DeviceBank;
+import com.bitwig.extension.controller.api.TrackBank;
+
+/**
+ * Wraps a cursor and bank in order to provide a normalized 0-1 value for the cursor position.
+ *
+ * Sends -1.0 if there are no items in the bank.
+ */
+public class CursorNormalizedValue
+{
+ private final Set observers = new HashSet<>();
+ private int cursorIndex = -1;
+ private int cursorCount = -1;
+
+ /**
+ * Creates a wrapper for a track cursor.
+ *
+ * @param cursorTrack The track cursor to wrap.
+ * @param trackBank The bank for the cursor.
+ */
+ public CursorNormalizedValue(CursorTrack cursorTrack, TrackBank trackBank)
+ {
+ trackBank.channelCount().markInterested();
+ trackBank.channelCount().addValueObserver(this::setCursorCount);
+
+ cursorTrack.position().markInterested();
+ cursorTrack.position().addValueObserver(this::setCursorIndex);
+ }
+
+ /**
+ * Creates a wrapper for a device cursor.
+ *
+ * @param cursorDevice The device cursor to wrap.
+ * @param deviceBank The bank for the cursor.
+ */
+ public CursorNormalizedValue(CursorDevice cursorDevice, DeviceBank deviceBank)
+ {
+ deviceBank.itemCount().markInterested();
+ deviceBank.itemCount().addValueObserver(this::setCursorCount);
+
+ cursorDevice.position().markInterested();
+ cursorDevice.position().addValueObserver(this::setCursorIndex);
+ }
+
+ /**
+ * Creates a wrapper for remote controls pages.
+ *
+ * @param cursorRemoteControlsPage The remote control pages to wrap.
+ */
+ public CursorNormalizedValue(CursorRemoteControlsPage cursorRemoteControlsPage)
+ {
+ cursorRemoteControlsPage.pageCount().markInterested();
+ cursorRemoteControlsPage.pageCount().addValueObserver(this::setCursorCount);
+
+ cursorRemoteControlsPage.selectedPageIndex().markInterested();
+ cursorRemoteControlsPage.selectedPageIndex().addValueObserver(this::setCursorIndex);
+ }
+
+ /**
+ * Adds and observer for the wrapped value.
+ *
+ * @param callback The observer callback.
+ *
+ * @return True if this set did not already contain the specified element.
+ */
+ public Boolean addValueObserver(DoubleValueChangedCallback callback)
+ {
+ return observers.add(callback);
+ }
+
+ /** Handles when the cursor index changes. */
+ private void setCursorIndex(int index)
+ {
+ cursorIndex = index;
+ updateCursorFeedback();
+ }
+
+ /** Handles when the bank item count changes. */
+ private void setCursorCount(int count)
+ {
+ cursorCount = count;
+ updateCursorFeedback();
+ }
+
+ /** Generates the normalized value and notifies observers. */
+ private void updateCursorFeedback()
+ {
+ if (cursorCount < 1 || cursorIndex < 0) {
+ notifyObservers(-1);
+ return;
+ }
+
+ if (cursorCount < 2) {
+ notifyObservers(0);
+ return;
+ }
+
+ final double normalized = cursorIndex / (cursorCount - 1.0);
+ notifyObservers(MathUtil.clamp(normalized, 0.0, 1.0));
+ }
+
+ private void notifyObservers(double value)
+ {
+ for (DoubleValueChangedCallback observer : observers) {
+ observer.valueChanged(value);
+ }
+ }
+}
diff --git a/src/main/java/io/github/dozius/util/DevUtil.java b/src/main/java/io/github/dozius/util/DevUtil.java
new file mode 100644
index 0000000..75d0dc0
--- /dev/null
+++ b/src/main/java/io/github/dozius/util/DevUtil.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.util;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.bitwig.extension.controller.api.Action;
+import com.bitwig.extension.controller.api.ActionCategory;
+import com.bitwig.extension.controller.api.Application;
+import com.bitwig.extension.controller.api.ControllerHost;
+
+/** Extension development utilities. */
+public class DevUtil
+{
+ /**
+ * Dumps all the available Bitwig actions to a text file.
+ *
+ * @param host The extension controller host.
+ * @param outputFile The path of the output file.
+ *
+ * @throws IOException
+ */
+ public static void dumpBitwigActions(ControllerHost host, Path outputFile) throws IOException
+ {
+ final Application app = host.createApplication();
+ final List output = new ArrayList<>();
+
+ for (ActionCategory actionCat : app.getActionCategories()) {
+ output.add(actionCat.getName());
+ for (Action action : actionCat.getActions()) {
+ output.add("--- ID: " + action.getId() + " Name: " + action.getName());
+ }
+ }
+
+ Files.write(outputFile, output, StandardCharsets.UTF_8);
+ }
+}
diff --git a/src/main/java/io/github/dozius/util/Logger.java b/src/main/java/io/github/dozius/util/Logger.java
new file mode 100644
index 0000000..42ff60a
--- /dev/null
+++ b/src/main/java/io/github/dozius/util/Logger.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.util;
+
+import com.bitwig.extension.controller.api.ControllerHost;
+
+/** Global logger intended for debugging. */
+public final class Logger
+{
+ private static ControllerHost host;
+
+ private Logger()
+ {
+ }
+
+ /**
+ * Initialize the Logger. Must be called before any logging can happen.
+ *
+ * @param controllerHost The extension controller host.
+ */
+ public static void init(ControllerHost controllerHost)
+ {
+ host = controllerHost;
+ }
+
+ /**
+ * Logs a message to the Bitwig console with a timestamp.
+ *
+ * @param message The message to log.
+ */
+ public static void logTimeStamped(String message)
+ {
+ log("[" + java.time.LocalTime.now().toString() + "] " + message);
+ }
+
+ /**
+ * Logs a message to the Bitwig console.
+ *
+ * @param message The message to log.
+ */
+ public static void log(String message)
+ {
+ if (host != null) {
+ host.println(message);
+ }
+ }
+}
diff --git a/src/main/java/io/github/dozius/util/MathUtil.java b/src/main/java/io/github/dozius/util/MathUtil.java
new file mode 100644
index 0000000..747ee5a
--- /dev/null
+++ b/src/main/java/io/github/dozius/util/MathUtil.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.util;
+
+/**
+ * Generic math utilities.
+ */
+public class MathUtil
+{
+ // clamp - note we don't use generics here to avoid boxing/unboxing
+
+ /**
+ * Clamps the value to be between min and max.
+ *
+ * @param val Value to clamp.
+ * @param min Clamp min.
+ * @param max Clamp max.
+ *
+ * @return The clamped value.
+ */
+ public static float clamp(float val, float min, float max)
+ {
+ return Math.max(min, Math.min(max, val));
+ }
+
+ /**
+ * Clamps the value to be between min and max.
+ *
+ * @param val Value to clamp.
+ * @param min Clamp min.
+ * @param max Clamp max.
+ *
+ * @return The clamped value.
+ */
+ public static int clamp(int val, int min, int max)
+ {
+ return Math.max(min, Math.min(max, val));
+ }
+
+ /**
+ * Clamps the value to be between min and max.
+ *
+ * @param val Value to clamp.
+ * @param min Clamp min.
+ * @param max Clamp max.
+ *
+ * @return The clamped value.
+ */
+ public static double clamp(double val, double min, double max)
+ {
+ return Math.max(min, Math.min(max, val));
+ }
+}
diff --git a/src/main/java/io/github/dozius/util/OnOffColorSupplier.java b/src/main/java/io/github/dozius/util/OnOffColorSupplier.java
new file mode 100644
index 0000000..94143b7
--- /dev/null
+++ b/src/main/java/io/github/dozius/util/OnOffColorSupplier.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2021 Dan Smith
+ *
+ * This file is part of Twister Sister.
+ *
+ * Twister Sister is free software: you can redistribute it and/or modify it under the terms of the
+ * GNU Lesser General Public License as published by the Free Software Foundation, either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * Twister Sister is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with Twister
+ * Sister. If not, see .
+ *
+ */
+package io.github.dozius.util;
+
+import java.util.function.Supplier;
+
+import com.bitwig.extension.api.Color;
+
+/** A color supplier that can toggle between two color states, an "on" color and "off" color. */
+public class OnOffColorSupplier implements Supplier
+{
+ private Color onColor;
+ private Color offColor;
+ private boolean isOn;
+
+ /**
+ * Constructs a new OnOffColorSupplier.
+ *
+ * @param onColor The color to use for the "on" state.
+ * @param offColor The color to use for the "off" state.
+ */
+ public OnOffColorSupplier(Color onColor, Color offColor)
+ {
+ this.onColor = onColor;
+ this.offColor = offColor;
+ }
+
+ /**
+ * Constructs a new OnOffColorSupplier with the "off" state set to black.
+ *
+ * @param onColor The color to use for the "on" state.
+ */
+ public OnOffColorSupplier(Color onColor)
+ {
+ this(onColor, Color.blackColor());
+ }
+
+ /** Constructs a new OnOffColorSupplier with the "on" and "off" state set to black. */
+ public OnOffColorSupplier()
+ {
+ this(Color.blackColor(), Color.blackColor());
+ }
+
+ /**
+ * Sets the color for the "on" state.
+ *
+ * @param onColor The desired color.
+ */
+ public void setOnColor(Color onColor)
+ {
+ this.onColor = onColor;
+ }
+
+ /**
+ * Sets the color for the "off" state.
+ *
+ * @param offColor The desired color.
+ */
+ public void setOffColor(Color offColor)
+ {
+ this.offColor = offColor;
+ }
+
+ /**
+ * Sets the state.
+ *
+ * @param on "on" if true, "off" if false.
+ */
+ public void setOn(boolean on)
+ {
+ this.isOn = on;
+ }
+
+ /** @return The current state color. */
+ @Override
+ public Color get()
+ {
+ return isOn ? onColor : offColor;
+ }
+}
diff --git a/src/main/resources/META-INF/services/com.bitwig.extension.ExtensionDefinition b/src/main/resources/META-INF/services/com.bitwig.extension.ExtensionDefinition
new file mode 100644
index 0000000..de7f125
--- /dev/null
+++ b/src/main/resources/META-INF/services/com.bitwig.extension.ExtensionDefinition
@@ -0,0 +1 @@
+io.github.dozius.TwisterSisterExtensionDefinition