Repository: titanicsend/LXStudio-TE
Pull request #609: Audio Stems upgrade and conversion to maven module
Author: login: jkbelcher
Created: 2025-04-13T15:58:04Z | Merged: 2025-04-14T05:01:25Z

- Moved Audio Stems into a maven module.
- Ported changes from [https://github.com/jkbelcher/AudioStemsPlugin](https://github.com/jkbelcher/AudioStemsPlugin)
- Groups audio stem functions into a Chromatik plugin, laying groundwork for the module to be deployed independently as a Chromatik Package JAR.
- Stem labels and OSC paths can now be adjusted in the config file audioStems.json. This file can be edited to match settings in the [stem analyzer application], to support new flexibility implemented by @worldveil and @zranger1 .
- Modulator uses the original package name `titanicsend.audio`, to avoid breaking project files and presets.
- All existing project files and presets should load to the correct stem, even with the new dynamic stem source.
- Logs will no longer get flooded when receiving an unrecognized OSC stem path.

<img width="197" alt="image" src="https://github.com/user-attachments/assets/b49a3f15-f16d-4155-9ce0-e3314bc6754f" />



Diff:
diff --git a/audio-stems/pom.xml b/audio-stems/pom.xml
new file mode 100644
index 000000000..69110525f
--- /dev/null
+++ b/audio-stems/pom.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.titanicsend</groupId>
+        <artifactId>titanicsend-parent</artifactId>
+        <version>0.2.3-SNAPSHOT</version>
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+
+    <artifactId>audio-stems</artifactId>
+    <version>0.1.0-SNAPSHOT</version>
+    <name>Audio Stems Plugin</name>
+    <description>A Chromatik plugin that receives audio stem information over OSC and makes it available for real-time
+        display and mapping.
+    </description>
+    <inceptionYear>2025</inceptionYear>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.heronarts</groupId>
+            <artifactId>lx</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.heronarts</groupId>
+            <artifactId>glx</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.heronarts</groupId>
+            <artifactId>glxstudio</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.google.code.gson</groupId>
+            <artifactId>gson</artifactId>
+        </dependency>
+    </dependencies>
+</project>
\ No newline at end of file
diff --git a/te-app/src/main/java/titanicsend/audio/AudioStemModulator.java b/audio-stems/src/main/java/titanicsend/audio/AudioStemModulator.java
similarity index 81%
rename from te-app/src/main/java/titanicsend/audio/AudioStemModulator.java
rename to audio-stems/src/main/java/titanicsend/audio/AudioStemModulator.java
index 5138ad5ce..a7e7dc5c4 100644
--- a/te-app/src/main/java/titanicsend/audio/AudioStemModulator.java
+++ b/audio-stems/src/main/java/titanicsend/audio/AudioStemModulator.java
@@ -22,45 +22,10 @@ public class AudioStemModulator extends LXModulator
     implements LXOscComponent, LXNormalizedParameter, UIModulatorControls<AudioStemModulator> {
 
   public static final double MAX_SMOOTHING_MS = 1000;
-
   // Accumulator rate in maximum units per second
   public static final double MAX_ACCUMULATOR_RATE = 8.0;
-  public double accumulator = 0.0;
-
-  public static enum Stem {
-    BASS("Bass") {
-      @Override
-      public double getValue() {
-        return AudioStems.get().bass.getValue();
-      }
-    },
-    DRUMS("Drums") {
-      @Override
-      public double getValue() {
-        return AudioStems.get().drums.getValue();
-      }
-    },
-    VOCALS("Vocals") {
-      @Override
-      public double getValue() {
-        return AudioStems.get().vocals.getValue();
-      }
-    },
-    OTHER("Other") {
-      @Override
-      public double getValue() {
-        return AudioStems.get().other.getValue();
-      }
-    };
-
-    public final String label;
-
-    private Stem(String label) {
-      this.label = label;
-    }
 
-    public abstract double getValue();
-  }
+  public final AudioStems.Selector stem;
 
   public enum OutputMode {
     ENERGY("Energy"), // raw stem energy value
@@ -79,17 +44,13 @@ public String toString() {
   }
 
   public final EnumParameter<OutputMode> outputMode =
-      new EnumParameter<OutputMode>("Mode", OutputMode.ENERGY).setDescription("Stem output mode");
+      new EnumParameter<>("Mode", OutputMode.ENERGY).setDescription("Stem output mode");
 
   public final CompoundParameter accRate =
       new CompoundParameter("Rate", 0.2, 0.2, MAX_ACCUMULATOR_RATE)
           .setDescription("Wave mode frequency (Accumulation rate in stem energy units/sec)")
           .setExponent(2);
 
-  public final EnumParameter<Stem> stem =
-      new EnumParameter<Stem>("Stem", Stem.BASS)
-          .setDescription("Which audio stem is the source for this modulator");
-
   public final CompoundParameter emaMs =
       new CompoundParameter("EMA", 0, 0, MAX_SMOOTHING_MS)
           .setDescription("Length of EMA smoothing time applied to the input, in milliseconds")
@@ -97,7 +58,7 @@ public String toString() {
           .setUnits(Units.MILLISECONDS);
 
   public final ObjectParameter<LXWaveshape> waveshape =
-      new ObjectParameter<LXWaveshape>(
+      new ObjectParameter<>(
               "Shape",
               new LXWaveshape[] {
                 LXWaveshape.SIN,
@@ -113,6 +74,8 @@ public String toString() {
 
   private final EMA ema = new EMA(0);
 
+  private double accumulator = 0.0;
+
   public AudioStemModulator() {
     this("Audio Stem");
   }
@@ -120,14 +83,14 @@ public AudioStemModulator() {
   public AudioStemModulator(String label) {
     super(label);
 
+    this.stem =
+        AudioStems.get().newSelector("Stem", "Which audio stem is the source for this modulator");
     addParameter("stem", this.stem);
     addParameter("emaMs", this.emaMs);
     addParameter("outputMode", this.outputMode);
     addParameter("accRate", this.accRate);
     addParameter("shape", this.waveshape);
     addParameter("slope", this.slope);
-
-    addLegacyParameter("noiseMode", this.outputMode);
   }
 
   @Override
@@ -144,7 +107,8 @@ private void updatePeriod() {
 
   @Override
   protected double computeValue(double deltaMs) {
-    double input = this.stem.getEnum().getValue();
+    AudioStems.Stem stem = this.stem.getObject();
+    double input = stem != null ? stem.getValue() : 0.0;
     double r = this.ema.update(input, deltaMs);
 
     if (this.outputMode.getEnum() == OutputMode.WAVE) {
diff --git a/audio-stems/src/main/java/titanicsend/audio/AudioStems.java b/audio-stems/src/main/java/titanicsend/audio/AudioStems.java
new file mode 100644
index 000000000..2254c7cba
--- /dev/null
+++ b/audio-stems/src/main/java/titanicsend/audio/AudioStems.java
@@ -0,0 +1,205 @@
+package titanicsend.audio;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import heronarts.lx.LX;
+import heronarts.lx.LXComponent;
+import heronarts.lx.osc.LXOscListener;
+import heronarts.lx.osc.OscMessage;
+import heronarts.lx.parameter.BoundedFunctionalParameter;
+import heronarts.lx.parameter.BoundedParameter;
+import heronarts.lx.parameter.CompoundParameter;
+import heronarts.lx.parameter.LXParameter.Units;
+import heronarts.lx.parameter.ObjectParameter;
+import heronarts.lx.utils.LXUtils;
+import heronarts.lx.utils.ObservableList;
+import java.io.File;
+import java.io.FileReader;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/** Top-level component for Audio Stems, runs as a child of LX Engine */
+public class AudioStems extends LXComponent implements LXOscListener {
+
+  private static AudioStems current;
+
+  public static AudioStems get() {
+    return current;
+  }
+
+  private final ObservableList<Stem> mutableStems = new ObservableList<>();
+  public final ObservableList<Stem> stems = mutableStems.asUnmodifiableList();
+
+  private Stem[] selectorObjects = new Stem[] {null};
+  private String[] selectorOptions = new String[] {null};
+  private final List<Selector> selectors = new ArrayList<>();
+
+  public final CompoundParameter gain =
+      new CompoundParameter("Gain", 0, -1, 2).setUnits(Units.PERCENT_NORMALIZED);
+
+  public AudioStems(LX lx) {
+    super(lx, "audioStems");
+    if (current != null) {
+      throw new IllegalStateException("Only one instance of AudioStems engine is permitted");
+    }
+    current = this;
+    addParameter("gain", this.gain);
+    loadConfig(lx);
+    this.lx.engine.osc.addListener(this);
+  }
+
+  private void addStem(Stem stem) {
+    this.mutableStems.add(stem);
+    updateSelectors();
+  }
+
+  @SuppressWarnings("unused")
+  private void removeStem(Stem stem) {
+    if (!this.stems.contains(stem)) {
+      throw new IllegalStateException("Cannot remove unknown stem: " + stem.label);
+    }
+    this.mutableStems.remove(stem);
+    updateSelectors();
+  }
+
+  private void updateSelectors() {
+    int numOptions = this.stems.size();
+    this.selectorObjects = new Stem[numOptions];
+    this.selectorOptions = new String[numOptions];
+    for (int i = 0; i < numOptions; i++) {
+      Stem stem = this.stems.get(i);
+      this.selectorObjects[i] = stem;
+      this.selectorOptions[i] = stem.label;
+    }
+    for (Selector selector : this.selectors) {
+      final Stem selected = selector.getObject();
+      selector.setObjects(this.selectorObjects, this.selectorOptions);
+      if ((selected != selector.getObject()) && this.stems.contains(selected)) {
+        selector.setValue(selected);
+      }
+    }
+  }
+
+  public Selector newSelector(String label, String description) {
+    return (Selector) new Selector(label).setDescription(description);
+  }
+
+  @Override
+  public void oscMessage(OscMessage message) {
+    String oscPath = message.getAddressPattern().getValue();
+    for (Stem stem : this.stems) {
+      if (oscPath.equals(stem.oscPath)) {
+        float value = message.getFloat();
+        stem.rawParameter.setValue(value);
+      }
+    }
+  }
+
+  private static final String CONFIG_DEFAULT_FILENAME = "audioStems.default.json";
+  private static final String CONFIG_FILENAME = "audioStems.json";
+  private static final String KEY_STEMS = "stems";
+
+  private void loadConfig(LX lx) {
+    File file = lx.getMediaFile(CONFIG_FILENAME);
+
+    // On first run, copy the config file out of the package jar
+    if (!file.exists()) {
+      try (InputStream resourceStream =
+          getClass().getResourceAsStream("/" + CONFIG_DEFAULT_FILENAME)) {
+        if (resourceStream == null) {
+          throw new IllegalStateException(
+              "Template config file " + CONFIG_DEFAULT_FILENAME + " not found in package");
+        }
+        Files.copy(resourceStream, file.toPath());
+      } catch (Exception e) {
+        LOG.error(e, "Failed to copy config file from resources: " + CONFIG_DEFAULT_FILENAME);
+      }
+    }
+
+    // Load from file
+    if (file.exists()) {
+      try (FileReader fr = new FileReader(file)) {
+        JsonObject obj = new Gson().fromJson(fr, JsonObject.class);
+        if (obj.has(KEY_STEMS)) {
+          JsonArray stemsArray = obj.getAsJsonArray(KEY_STEMS);
+          for (JsonElement stemElement : stemsArray) {
+            JsonObject stemObj = stemElement.getAsJsonObject();
+            String label = stemObj.get("label").getAsString();
+            String oscPath = stemObj.get("oscPath").getAsString();
+            Stem stem = new Stem(label, oscPath);
+            addStem(stem);
+          }
+        }
+      } catch (Exception x) {
+        LOG.error(x, "Exception loading audio stems config file: " + file);
+      }
+    } else {
+      LOG.error("Audio stems config file not found: " + file);
+    }
+  }
+
+  @Override
+  public void dispose() {
+    this.lx.engine.osc.removeListener(this);
+    super.dispose();
+  }
+
+  public class Stem {
+
+    public final String label;
+    public final String oscPath;
+    public final BoundedParameter rawParameter;
+    public final BoundedFunctionalParameter parameter;
+
+    private Stem(String label, String oscPath) {
+      if (LXUtils.isEmpty(label)) {
+        throw new IllegalArgumentException("Audio stem label cannot be empty");
+      }
+      this.label = label;
+      this.oscPath = Objects.requireNonNull(oscPath).trim();
+
+      this.rawParameter = new BoundedParameter(label + "Raw");
+      this.parameter =
+          new BoundedFunctionalParameter(label) {
+            @Override
+            protected double computeValue() {
+              // Apply adjustments (gain, smoothing) to a raw parameter
+              return rawParameter.getValue() * (1.0 + gain.getValue());
+            }
+          }.setDescription("Audio stem for " + label);
+    }
+
+    /** Get the stem's current raw value prior to gain or smoothing. */
+    public double getValueRaw() {
+      return this.rawParameter.getValue();
+    }
+
+    /** Get the stem's current value with global gain applied. */
+    public double getValue() {
+      return this.parameter.getValue();
+    }
+  }
+
+  public class Selector extends ObjectParameter<Stem> {
+
+    public Selector(String label) {
+      this(label, selectorObjects, selectorOptions);
+      selectors.add(this);
+    }
+
+    private Selector(String label, Stem[] objects, String[] options) {
+      super(label, objects, options);
+    }
+
+    @Override
+    public void dispose() {
+      selectors.remove(this);
+      super.dispose();
+    }
+  }
+}
diff --git a/audio-stems/src/main/java/titanicsend/audio/AudioStemsPlugin.java b/audio-stems/src/main/java/titanicsend/audio/AudioStemsPlugin.java
new file mode 100644
index 000000000..976d243d9
--- /dev/null
+++ b/audio-stems/src/main/java/titanicsend/audio/AudioStemsPlugin.java
@@ -0,0 +1,69 @@
+package titanicsend.audio;
+
+import heronarts.lx.LX;
+import heronarts.lx.LXPlugin;
+import heronarts.lx.studio.LXStudio;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Properties;
+
+@LXPlugin.Name("Audio Stems")
+public class AudioStemsPlugin implements LXStudio.Plugin {
+
+  private AudioStems audioStems;
+
+  public AudioStemsPlugin(LX lx) {
+    LOG.log("AudioStemsPlugin(LX) version: " + loadVersion());
+  }
+
+  @Override
+  public void initialize(LX lx) {
+    lx.engine.registerComponent("audioStems", this.audioStems = new AudioStems(lx));
+
+    // This will get picked up by the package import, no need to directly add.
+    // lx.registry.addModulator(AudioStemModulator.class);
+  }
+
+  @Override
+  public void initializeUI(LXStudio lxStudio, LXStudio.UI ui) {}
+
+  @Override
+  public void onUIReady(LXStudio lxStudio, LXStudio.UI ui) {
+    new UIAudioStems(ui, this.audioStems, ui.leftPane.global.getContentWidth())
+        .addToContainer(ui.leftPane.global, 2);
+
+    new UIAudioStems(ui, this.audioStems, ui.leftPerformance.tools.getContentWidth())
+        .addToContainer(ui.leftPerformance.tools, 0);
+  }
+
+  @Override
+  public void dispose() {
+    this.audioStems.dispose();
+  }
+
+  /**
+   * Projects that import this library and are NOT an LXPackage (such as a custom build) should call
+   * this method from their initialize().
+   */
+  public static void registerComponents(LX lx) {
+    lx.registry.addModulator(AudioStemModulator.class);
+  }
+
+  /**
+   * Loads 'audioStems.properties', after maven resource filtering has been applied. Note that you
+   * may need to run `mvn clean package` once from inside `audio-stems` directory to generate the
+   * templated properties file. To verify: `cat target/classes/audioStems.properties`.
+   */
+  private String loadVersion() {
+    String version = "";
+    Properties properties = new Properties();
+    try (InputStream inputStream =
+        getClass().getClassLoader().getResourceAsStream("audioStems.properties")) {
+      properties.load(inputStream);
+      version = properties.getProperty("audioStems.version");
+    } catch (IOException e) {
+      LOG.error("Failed to load version information " + e);
+    }
+    return version;
+  }
+}
diff --git a/audio-stems/src/main/java/titanicsend/audio/LOG.java b/audio-stems/src/main/java/titanicsend/audio/LOG.java
new file mode 100644
index 000000000..34cf2734a
--- /dev/null
+++ b/audio-stems/src/main/java/titanicsend/audio/LOG.java
@@ -0,0 +1,44 @@
+/**
+ * Chromatik Plugin Log
+ *
+ * @author Justin Belcher <justin@jkb.studio>
+ */
+package titanicsend.audio;
+
+import heronarts.lx.LX;
+
+/**
+ * Provides static logging methods within the context of a plugin that prepend a plugin-specific
+ * string before passing to LX logging methods.
+ */
+public class LOG {
+
+  // Adjust per-plugin
+  public static final String PREFIX = "AudioStems";
+
+  private static final String LOG_PREFIX = "[" + PREFIX + "] ";
+
+  private static String prefix(String message) {
+    return LOG_PREFIX + (message != null ? message : "");
+  }
+
+  public static void log(String message) {
+    LX.log(prefix(message));
+  }
+
+  public static void warning(String message) {
+    LX.warning(prefix(message));
+  }
+
+  public static void error(String message) {
+    LX.error(prefix(message));
+  }
+
+  public static void error(Throwable x) {
+    LX.error(x, prefix(x.getClass().getName() + ":" + x.getLocalizedMessage()));
+  }
+
+  public static void error(Throwable x, String message) {
+    LX.error(x, prefix(message));
+  }
+}
diff --git a/audio-stems/src/main/java/titanicsend/audio/UIAudioStems.java b/audio-stems/src/main/java/titanicsend/audio/UIAudioStems.java
new file mode 100644
index 000000000..9714c24db
--- /dev/null
+++ b/audio-stems/src/main/java/titanicsend/audio/UIAudioStems.java
@@ -0,0 +1,47 @@
+package titanicsend.audio;
+
+import heronarts.glx.ui.UI;
+import heronarts.glx.ui.UI2dComponent;
+import heronarts.glx.ui.UI2dContainer;
+import heronarts.glx.ui.component.UICollapsibleSection;
+import heronarts.glx.ui.component.UIDoubleBox;
+import heronarts.glx.ui.component.UILabel;
+import heronarts.glx.ui.component.UIMeter;
+import heronarts.glx.ui.vg.VGraphics;
+
+public class UIAudioStems extends UICollapsibleSection {
+
+  private static final float VERTICAL_SPACING = 4;
+  private static final float BREAK_INSET = 2;
+
+  public UIAudioStems(UI ui, AudioStems audioStems, float w) {
+    super(ui, 0, 0, w, 0);
+    this.setTitle("AUDIO STEMS");
+    this.setLayout(Layout.VERTICAL, VERTICAL_SPACING);
+    this.setPadding(2, 0);
+
+    for (AudioStems.Stem stem : audioStems.stems) {
+      newRow(ui, stem).addToContainer(this);
+    }
+
+    addChildren(
+        new UI2dComponent(BREAK_INSET, 0, getWidth() - (2 * BREAK_INSET), 1) {}.setBackgroundColor(
+                ui.theme.paneInsetColor)
+            .setMargin(2, 0),
+        UI2dContainer.newHorizontalContainer(
+            16,
+            4,
+            new UILabel.Control(ui, 0, 0, 34, 16, audioStems.gain.getLabel()),
+            new UIDoubleBox(26, 0, 50, 16, audioStems.gain)));
+  }
+
+  private UI2dContainer newRow(UI ui, AudioStems.Stem stem) {
+    return UI2dContainer.newHorizontalContainer(
+        16,
+        4,
+        new UILabel(0, 0, 50, 12, stem.label)
+            .setFont(ui.theme.getControlFont())
+            .setTextAlignment(VGraphics.Align.LEFT, VGraphics.Align.MIDDLE),
+        new UIMeter(ui, stem.parameter, UIMeter.Axis.HORIZONTAL, 8, 1, 50, 14));
+  }
+}
diff --git a/te-app/src/main/java/titanicsend/util/EMA.java b/audio-stems/src/main/java/titanicsend/audio/util/EMA.java
similarity index 100%
rename from te-app/src/main/java/titanicsend/util/EMA.java
rename to audio-stems/src/main/java/titanicsend/audio/util/EMA.java
diff --git a/audio-stems/src/main/resources/audioStems.default.json b/audio-stems/src/main/resources/audioStems.default.json
new file mode 100644
index 000000000..32f5257c0
--- /dev/null
+++ b/audio-stems/src/main/resources/audioStems.default.json
@@ -0,0 +1,20 @@
+{
+  "stems": [
+    {
+      "label": "Bass",
+      "oscPath": "/te/stem/bass"
+    },
+    {
+      "label": "Drums",
+      "oscPath": "/te/stem/drums"
+    },
+    {
+      "label": "Vocals",
+      "oscPath": "/te/stem/vocals"
+    },
+    {
+      "label": "Other",
+      "oscPath": "/te/stem/other"
+    }
+  ]
+}
diff --git a/audio-stems/src/main/resources/audioStems.properties b/audio-stems/src/main/resources/audioStems.properties
new file mode 100644
index 000000000..0ee7c5345
--- /dev/null
+++ b/audio-stems/src/main/resources/audioStems.properties
@@ -0,0 +1 @@
+audioStems.version=${project.version}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 6fc85092f..f16b59458 100644
--- a/pom.xml
+++ b/pom.xml
@@ -15,12 +15,15 @@
     </description>
 
     <modules>
+        <module>audio-stems</module>
         <module>te-app</module>
     </modules>
 
     <properties>
         <!-- https://stackoverflow.com/a/43103038 -->
         <maven.compiler.release>21</maven.compiler.release>
+        <maven.compiler.source>21</maven.compiler.source>
+        <maven.compiler.target>21</maven.compiler.target>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 
         <!-- Plugin versions -->
@@ -42,6 +45,31 @@
         <supermod.version>0.1.4-SNAPSHOT</supermod.version>
     </properties>
 
+    <!-- Define a local repository -->
+    <repositories>
+        <repository>
+            <id>project-repo</id>
+            <name>project-repo</name>
+            <url>file://${project.basedir}/repo</url>
+            <layout>default</layout>
+            <releases>
+                <enabled>true</enabled>
+                <checksumPolicy>ignore</checksumPolicy>
+            </releases>
+            <snapshots>
+                <enabled>true</enabled>
+                <checksumPolicy>ignore</checksumPolicy>
+            </snapshots>
+        </repository>
+
+        <repository>
+            <id>jogamp-repo</id>
+            <name>jogamp-repo</name>
+            <url>https://jogamp.org/deployment/maven/</url>
+            <layout>default</layout>
+        </repository>
+    </repositories>
+
     <build>
         <pluginManagement>
             <plugins>
@@ -70,6 +98,12 @@
                 </plugin>
             </plugins>
         </pluginManagement>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+            </resource>
+        </resources>
     </build>
 
     <dependencyManagement>
diff --git a/te-app/audioStems.json b/te-app/audioStems.json
new file mode 100644
index 000000000..32f5257c0
--- /dev/null
+++ b/te-app/audioStems.json
@@ -0,0 +1,20 @@
+{
+  "stems": [
+    {
+      "label": "Bass",
+      "oscPath": "/te/stem/bass"
+    },
+    {
+      "label": "Drums",
+      "oscPath": "/te/stem/drums"
+    },
+    {
+      "label": "Vocals",
+      "oscPath": "/te/stem/vocals"
+    },
+    {
+      "label": "Other",
+      "oscPath": "/te/stem/other"
+    }
+  ]
+}
diff --git a/te-app/pom.xml b/te-app/pom.xml
index 2e9b272f4..d274f5b2b 100644
--- a/te-app/pom.xml
+++ b/te-app/pom.xml
@@ -14,31 +14,6 @@
     <artifactId>te-app</artifactId>
     <name>Titanic's End LX Studio Application</name>
 
-    <!-- Define a local repository -->
-    <repositories>
-        <repository>
-            <id>project-repo</id>
-            <name>project-repo</name>
-            <url>file://${project.parent.basedir}/repo</url>
-            <layout>default</layout>
-            <releases>
-                <enabled>true</enabled>
-                <checksumPolicy>ignore</checksumPolicy>
-            </releases>
-            <snapshots>
-                <enabled>true</enabled>
-                <checksumPolicy>ignore</checksumPolicy>
-            </snapshots>
-        </repository>
-
-        <repository>
-            <id>jogamp-repo</id>
-            <name>jogamp-repo</name>
-            <url>https://jogamp.org/deployment/maven/</url>
-            <layout>default</layout>
-        </repository>
-    </repositories>
-
     <dependencies>
         <dependency>
             <groupId>com.google.code.gson</groupId>
@@ -120,6 +95,12 @@
             <groupId>com.jkbstudio</groupId>
             <artifactId>supermod</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>com.titanicsend</groupId>
+            <artifactId>audio-stems</artifactId>
+            <version>0.1.0-SNAPSHOT</version>
+        </dependency>
     </dependencies>
 
     <build>
diff --git a/te-app/src/main/java/heronarts/lx/studio/TEApp.java b/te-app/src/main/java/heronarts/lx/studio/TEApp.java
index 60b498023..07e6a44a4 100644
--- a/te-app/src/main/java/heronarts/lx/studio/TEApp.java
+++ b/te-app/src/main/java/heronarts/lx/studio/TEApp.java
@@ -15,8 +15,6 @@
  */
 package heronarts.lx.studio;
 
-import static titanicsend.audio.AudioStemModulator.Stem.*;
-
 import heronarts.glx.event.GamepadEvent;
 import heronarts.glx.event.KeyEvent;
 import heronarts.lx.LX;
@@ -47,7 +45,7 @@
 import titanicsend.app.director.UIDirector;
 import titanicsend.audio.AudioStemModulator;
 import titanicsend.audio.AudioStems;
-import titanicsend.audio.UIAudioStems;
+import titanicsend.audio.AudioStemsPlugin;
 import titanicsend.color.ColorPaletteManager;
 import titanicsend.color.TEGradientSource;
 import titanicsend.dmx.DmxEngine;
@@ -157,7 +155,6 @@ public static class Plugin implements LXStudio.Plugin, LX.Listener, LX.ProjectLi
     private final GLEngine glEngine;
     private final SuperMod superMod;
 
-    private final AudioStems audioStems;
     private final ColorPaletteManager paletteManagerA;
     private final ColorPaletteManager paletteManagerB;
     private final TELaserTask laserTask;
@@ -199,7 +196,6 @@ public Plugin(LX lx) {
       // Super Modulator midi controller
       this.superMod = new SuperMod(lx);
 
-      lx.engine.registerComponent("audioStems", this.audioStems = new AudioStems(lx));
       lx.engine.registerComponent(
           "paletteManagerA", this.paletteManagerA = new ColorPaletteManager(lx));
       if (UIColorPaletteManager.DISPLAY_TWO_MANAGED_SWATCHES) {
@@ -237,6 +233,9 @@ public void initialize(LX lx) {
 
       log("TEApp.Plugin.initialize()");
 
+      // Register child plugin components
+      AudioStemsPlugin.registerComponents(lx);
+
       if (staticModel) {
         GrandShlomoStation.activateAll(lx, wholeModel.getGapPointIndices()[0]);
       }
@@ -420,7 +419,6 @@ public void initialize(LX lx) {
       this.superMod.registerMidiFighterTwister(MidiNames.BOMEBOX_MIDIFIGHTERTWISTER4);
 
       // Custom modulators
-      lx.registry.addModulator(AudioStemModulator.class);
       lx.registry.addModulator(Dmx16bitModulator.class);
       lx.registry.addModulator(DmxGridModulator.class);
       // Replaced by Chromatik version:
@@ -695,40 +693,31 @@ public void initializeAutopilotLibraryJKB() {
           .addParameter(new AutoParameter("sharp", Scale.ABSOLUTE, -.5, .5, .3));
     }
 
+    private static final int SUPERMOD_STEM_COLUMN_START = 0;
+    private static final int SUPERMOD_STEM_COLUMNS = 4;
+    private static final int SUPERMOD_STEM_ROW_ENERGY = 0;
+    private static final int SUPERMOD_STEM_ROW_WAVE = 1;
+
     /** Redirect some of the SuperMod buttons to Audio Stem modulators */
     private final SuperMod.ModulatorSource superModSource =
         (label, col, row) -> {
-          AudioStemModulator m = null;
-          if (row >= 0 && row <= 1) {
-            if (col == 0) {
-              m = new AudioStemModulator(label + " " + BASS);
-              m.stem.setValue(BASS);
-            }
-            if (col == 1 && row == 0) {
-              m = new AudioStemModulator(label + " " + DRUMS);
-              m.stem.setValue(DRUMS);
-            }
-            if (col == 2 && row == 0) {
-              m = new AudioStemModulator(label + " " + VOCALS);
-              m.stem.setValue(VOCALS);
-            }
-            if (col == 3 && row == 0) {
-              m = new AudioStemModulator(label + " " + OTHER);
-              m.stem.setValue(OTHER);
-            }
-
-            // Row 0 = normal, Row 1 = wave
-            if (m != null) {
-              if (row == 0) {
-                m.outputMode.setValue(AudioStemModulator.OutputMode.ENERGY);
-              } else if (row == 1) {
-                m.outputMode.setValue(AudioStemModulator.OutputMode.WAVE);
-              }
+          if (col >= SUPERMOD_STEM_COLUMN_START
+              && col < (SUPERMOD_STEM_COLUMN_START + SUPERMOD_STEM_COLUMNS)
+              && (row == SUPERMOD_STEM_ROW_ENERGY || row == SUPERMOD_STEM_ROW_WAVE)) {
+
+            AudioStems.Stem stem = AudioStems.get().stems.get(col);
+            AudioStemModulator m = new AudioStemModulator(label + " " + stem.label);
+            m.stem.setValue(stem);
+            if (row == SUPERMOD_STEM_ROW_ENERGY) {
+              m.outputMode.setValue(AudioStemModulator.OutputMode.ENERGY);
+            } else {
+              m.outputMode.setValue(AudioStemModulator.OutputMode.WAVE);
             }
+            return m;
           }
 
-          // If button was not in our space, m will be null
-          return m;
+          // If button was not in target range, decline opportunity to build custom modulator
+          return null;
         };
 
     public void initializeUI(LXStudio lx, LXStudio.UI ui) {
@@ -773,11 +762,9 @@ public void onUIReady(LXStudio lx, LXStudio.UI ui) {
       // new UIAutopilot(ui, this.autopilotJKB, ui.leftPane.global.getContentWidth())
       //    .addToContainer(ui.leftPane.global, 7);
 
-      // Add UI section for audio stems
-      new UIAudioStems(ui, this.audioStems, ui.leftPane.global.getContentWidth())
-          .addToContainer(ui.leftPane.global, 2);
-      new UIAudioStems(ui, this.audioStems, ui.leftPerformance.tools.getContentWidth())
-          .addToContainer(ui.leftPerformance.tools, 0);
+      // Add UI section for User Presets
+      new UIUserPresetManager(ui, lx, ui.leftPane.content.getContentWidth())
+          .addToContainer(ui.leftPane.content, 2);
 
       // Add UI section for User Presets
       new UIUserPresetManager(ui, lx, ui.leftPane.content.getContentWidth())
@@ -973,7 +960,6 @@ public static void main(String[] args) {
             + System.getProperty("os.version")
             + " "
             + System.getProperty("os.arch"));
-
     LX.LOG_WARNINGS = true;
 
     Flags flags = new Flags();
@@ -981,6 +967,7 @@ public static void main(String[] args) {
     flags.windowWidth = WINDOW_WIDTH;
     flags.windowHeight = WINDOW_HEIGHT;
     flags.zeroconf = false;
+    flags.classpathPlugins.add(titanicsend.audio.AudioStemsPlugin.class.getTypeName());
     flags.classpathPlugins.add("heronarts.lx.studio.TEApp$Plugin");
 
     // Always use OpenGL back-end for BGFX on Linux'
diff --git a/te-app/src/main/java/titanicsend/audio/AudioStems.java b/te-app/src/main/java/titanicsend/audio/AudioStems.java
deleted file mode 100644
index 952b7d13b..000000000
--- a/te-app/src/main/java/titanicsend/audio/AudioStems.java
+++ /dev/null
@@ -1,143 +0,0 @@
-package titanicsend.audio;
-
-import heronarts.lx.LX;
-import heronarts.lx.LXComponent;
-import heronarts.lx.osc.LXOscListener;
-import heronarts.lx.osc.OscMessage;
-import heronarts.lx.parameter.BoundedFunctionalParameter;
-import heronarts.lx.parameter.BoundedParameter;
-import heronarts.lx.parameter.CompoundParameter;
-import heronarts.lx.parameter.LXParameter.Units;
-
-public class AudioStems extends LXComponent implements LXOscListener {
-
-  public static final String PATH_STEM = "/te/stem/";
-  public static final String PATH_BASS = "bass";
-  public static final String PATH_DRUMS = "drums";
-  public static final String PATH_VOCALS = "vocals";
-  public static final String PATH_OTHER = "other";
-
-  private static AudioStems current;
-
-  public static AudioStems get() {
-    return current;
-  }
-
-  public final CompoundParameter gain =
-      new CompoundParameter("Gain", 0, -1, 2).setUnits(Units.PERCENT_NORMALIZED);
-
-  /*
-   * Raw input values
-   */
-
-  public final BoundedParameter bassRaw = new BoundedParameter("bassRaw");
-  public final BoundedParameter drumsRaw = new BoundedParameter("drumsRaw");
-  public final BoundedParameter vocalsRaw = new BoundedParameter("vocalsRaw");
-  public final BoundedParameter otherRaw = new BoundedParameter("otherRaw");
-
-  /*
-   * Values after gain, smoothing, etc.
-   */
-
-  public final BoundedFunctionalParameter bass =
-      new BoundedFunctionalParameter("Bass") {
-        @Override
-        protected double computeValue() {
-          return adjusted(bassRaw);
-        }
-      }.setDescription("Audio stem for bass");
-
-  public final BoundedFunctionalParameter drums =
-      new BoundedFunctionalParameter("Drums") {
-        @Override
-        protected double computeValue() {
-          return adjusted(drumsRaw);
-        }
-      }.setDescription("Audio stem for drums");
-
-  public final BoundedFunctionalParameter vocals =
-      new BoundedFunctionalParameter("Vocals") {
-        @Override
-        protected double computeValue() {
-          return adjusted(vocalsRaw);
-        }
-      }.setDescription("Audio stem for vocals");
-
-  public final BoundedFunctionalParameter other =
-      new BoundedFunctionalParameter("Other") {
-        @Override
-        protected double computeValue() {
-          return adjusted(otherRaw);
-        }
-      }.setDescription("Audio stem for other");
-
-  /** Apply adjustments (gain, smoothing) to a raw parameter */
-  private double adjusted(BoundedParameter raw) {
-    return raw.getValue() * (1.0 + gain.getValue());
-  }
-
-  public AudioStems(LX lx) {
-    super(lx, "audioStems");
-    current = this;
-
-    addParameter("gain", this.gain);
-    addParameter("bass", this.bass);
-    addParameter("drums", this.drums);
-    addParameter("vocals", this.vocals);
-    addParameter("other", this.other);
-
-    this.lx.engine.osc.addListener(this);
-  }
-
-  /** Starting point for OSC input. Called for messages received by the LX OSC Receiver. */
-  @Override
-  public void oscMessage(OscMessage message) {
-    String address = message.getAddressPattern().getValue();
-
-    if (address.startsWith(PATH_STEM)) {
-      address = address.trim();
-      if (address.length() == PATH_STEM.length()) {
-        LX.warning("Audio stem name not specified: " + address);
-        return;
-      }
-
-      float value = message.getFloat();
-
-      String stem = address.substring(PATH_STEM.length());
-      if (stem.equals(PATH_BASS)) {
-        handleBass(value);
-      } else if (stem.equals(PATH_DRUMS)) {
-        handleDrums(value);
-      } else if (stem.equals(PATH_VOCALS)) {
-        handleVocals(value);
-      } else if (stem.equals(PATH_OTHER)) {
-        handleOther(value);
-      } else {
-        LX.warning("Unknown audio stem path: " + address);
-        return;
-      }
-    }
-  }
-
-  private void handleBass(float value) {
-    this.bassRaw.setValue(value);
-  }
-
-  private void handleDrums(float value) {
-    this.drumsRaw.setValue(value);
-  }
-
-  private void handleVocals(float value) {
-    this.vocalsRaw.setValue(value);
-  }
-
-  private void handleOther(float value) {
-    this.otherRaw.setValue(value);
-  }
-
-  @Override
-  public void dispose() {
-    this.lx.engine.osc.removeListener(this);
-    super.dispose();
-  }
-}
diff --git a/te-app/src/main/java/titanicsend/audio/UIAudioStems.java b/te-app/src/main/java/titanicsend/audio/UIAudioStems.java
deleted file mode 100644
index 7c9452683..000000000
--- a/te-app/src/main/java/titanicsend/audio/UIAudioStems.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package titanicsend.audio;
-
-import heronarts.glx.ui.UI;
-import heronarts.glx.ui.UI2dContainer;
-import heronarts.glx.ui.component.UICollapsibleSection;
-import heronarts.glx.ui.component.UIDoubleBox;
-import heronarts.glx.ui.component.UILabel;
-import heronarts.glx.ui.component.UIMeter;
-import heronarts.lx.parameter.LXNormalizedParameter;
-
-public class UIAudioStems extends UICollapsibleSection {
-
-  private static final float VERTICAL_SPACING = 6;
-
-  public UIAudioStems(UI ui, AudioStems audioStems, float w) {
-    super(ui, 0, 0, w, 0);
-    this.setTitle("AUDIO STEMS");
-    this.setLayout(Layout.VERTICAL, VERTICAL_SPACING);
-    this.setPadding(2, 0);
-
-    UI2dContainer meters = UI2dContainer.newHorizontalContainer(50, 4);
-    addMeter(ui, meters, audioStems.bass);
-    addMeter(ui, meters, audioStems.drums);
-    addMeter(ui, meters, audioStems.vocals);
-    addMeter(ui, meters, audioStems.other);
-    meters.setX(10);
-    meters.addToContainer(this);
-
-    UI2dContainer.newHorizontalContainer(
-            16,
-            10,
-            new UILabel.Control(ui, 0, 0, 24, 16, audioStems.gain.getLabel()),
-            new UIDoubleBox(26, 0, 54, 16, audioStems.gain))
-        .addToContainer(this);
-  }
-
-  private void addMeter(UI ui, UI2dContainer container, LXNormalizedParameter parameter) {
-    UI2dContainer.newVerticalContainer(
-            35,
-            0,
-            new UIMeter(ui, parameter, 8, 0, 12, 36),
-            new UILabel(0, 0, 35, 12, parameter.getLabel()))
-        .addToContainer(container);
-  }
-}
diff --git a/te-app/src/main/java/titanicsend/pattern/glengine/GLEngine.java b/te-app/src/main/java/titanicsend/pattern/glengine/GLEngine.java
index 0831a253b..4c87a0847 100644
--- a/te-app/src/main/java/titanicsend/pattern/glengine/GLEngine.java
+++ b/te-app/src/main/java/titanicsend/pattern/glengine/GLEngine.java
@@ -395,10 +395,9 @@ private void updatePerFrameUniforms() {
     perFrameUniformBlock.put((float) bassRatio); // bassRatio
     perFrameUniformBlock.put((float) trebleRatio); // trebleRatio
     perFrameUniformBlock.put((float) volumeRatio); // volumeRatio
-    perFrameUniformBlock.put(AudioStems.get().bass.getValuef()); // stemBass
-    perFrameUniformBlock.put(AudioStems.get().drums.getValuef()); // stemDrums
-    perFrameUniformBlock.put(AudioStems.get().vocals.getValuef()); // stemVocals
-    perFrameUniformBlock.put(AudioStems.get().other.getValuef()); // stemOther
+    for (AudioStems.Stem stem : AudioStems.get().stems) {
+      perFrameUniformBlock.put((float) stem.getValue());
+    }
 
     // set the palette size and colors
     setPaletteUniforms(perFrameUniformBlock);


Reviews:
user: login: andrewlook
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T22:45:39Z
user: login: andrewlook
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T22:47:31Z
user: login: andrewlook
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T22:54:20Z
user: login: andrewlook
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T23:00:54Z
user: login: andrewlook
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T23:02:22Z
user: login: andrewlook
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T23:07:44Z
user: login: andrewlook
body: This is looking great.

The main pieces of feedback I have are:

1. `audioStems.json`
2. Singleton instance management / coupling

## `audioStems.json`

~I noticed that there's one copy in the audio-stems module under `src/main/resources`. Depending on how this jar gets bundled as a plugin for others, this might end up on the classpath and potentially mask the versions of this JSON file used in the end-user's app. I also noticed that we have a version of this in `te-app/audioStems.json`, as opposed to `te-app/src/main/resources/audioStems.json`.~

~I would consider overloading `AudioStem`'s constructor (or singleton initializer) to optionally accept a custom filename (to look up in the integrator project's resource path), or maybe even a full file path (so the integrator project could define a non-standard location for it). In any case, I'd love to be able to make a copy like `teAudioStems.json`, then in `TEApp` I could do something like `AudioStemsPlugin.registerComponents(lx, "teAudioStems.json")` and I'd be 100% sure that my json file isn't accidentally getting resource-shadowed by `audio-stems/src/main/resources/audioStems.json`.~

Update: I misunderstood how this was working - I just re-read `loadConfig`, and it looks like it's only using the classpath resource loading to grab the default version from the library's `src/main/resources`, and putting the contents in the importing module's main directory.

## Singleton instance management / coupling

I made a note about singleton / constructor stuff in `AudioStems` and in `AudioStemsPlugin`. There are probably a few different ways to approach encapsulating this safely, but it might be smart to think through how integrating applications will be accessing this.

I wonder if the touchpoints in `te-app` should be doing something like `AudioStemsPlugin.get()` instead of `AudioStems.get()` - then maybe the API surface is a bit cleaner, if we could enforce that all accesses go through the plugin class?
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T23:21:32Z
user: login: andrewlook
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T23:35:39Z
user: login: andrewlook
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T00:50:31Z
user: login: jkbelcher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T03:27:47Z
user: login: jkbelcher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T03:31:41Z
user: login: jkbelcher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T03:33:20Z
user: login: jkbelcher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T03:35:49Z
user: login: jkbelcher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T03:39:20Z
user: login: jkbelcher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T03:51:39Z
user: login: jkbelcher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T04:38:06Z
user: login: andrewlook
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-14T04:42:51Z

Review comments:
diff_hunk: @@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.titanicsend</groupId>
+        <artifactId>titanicsend-parent</artifactId>
+        <version>0.2.3-SNAPSHOT</version>
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+
+    <artifactId>audio-stems</artifactId>
+    <version>0.1.0-SNAPSHOT</version>
+    <name>Audio Stems Plugin</name>
+    <description>A Chromatik plugin that receives audio stem information over OSC and makes it available for real-time display and mapping.</description>
+    <inceptionYear>2025</inceptionYear>
+
+    <properties>
+        <maven.compiler.source>21</maven.compiler.source>
+        <maven.compiler.target>21</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
path: audio-stems/pom.xml
user: login: andrewlook
body: I'll double-check, but I think you might be able to just inherit these properties from the parent pom
created_at: 2025-04-13T22:45:39Z
author_association: COLLABORATOR
diff_hunk: @@ -113,21 +74,25 @@ public String toString() {
 
   private final EMA ema = new EMA(0);
 
+  private double accumulator = 0.0;
+
   public AudioStemModulator() {
     this("Audio Stem");
   }
 
   public AudioStemModulator(String label) {
     super(label);
 
-    addParameter("stem", this.stem);
+    addParameter(
+        "stem",
+        this.stem =
+            AudioStems.get()
+                .newSelector("Stem", "Which audio stem is the source for this modulator"));
path: audio-stems/src/main/java/titanicsend/audio/AudioStemModulator.java
user: login: andrewlook
body: minor code style thing - given the fixed indentation rules we're using, i might split this into two lines

```java
this.stem = AudioStems.get().newSelector("Stem", "Which audio stem is the source for this modulator"));
addParameter("stem", this.stem);
```
created_at: 2025-04-13T22:47:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,203 @@
+package titanicsend.audio;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import heronarts.lx.LX;
+import heronarts.lx.LXComponent;
+import heronarts.lx.osc.LXOscListener;
+import heronarts.lx.osc.OscMessage;
+import heronarts.lx.parameter.BoundedFunctionalParameter;
+import heronarts.lx.parameter.BoundedParameter;
+import heronarts.lx.parameter.CompoundParameter;
+import heronarts.lx.parameter.LXParameter.Units;
+import heronarts.lx.parameter.ObjectParameter;
+import heronarts.lx.utils.LXUtils;
+import heronarts.lx.utils.ObservableList;
+import java.io.File;
+import java.io.FileReader;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/** Top-level component for Audio Stems, runs as a child of LX Engine */
+public class AudioStems extends LXComponent implements LXOscListener {
+
+  private static AudioStems current;
+
+  public static AudioStems get() {
+    return current;
+  }
+
+  private final ObservableList<Stem> mutableStems = new ObservableList<>();
+  public final ObservableList<Stem> stems = mutableStems.asUnmodifiableList();
+
+  private Stem[] selectorObjects = new Stem[] {null};
+  private String[] selectorOptions = new String[] {null};
+  private final List<Selector> selectors = new ArrayList<>();
+
+  public final CompoundParameter gain =
+      new CompoundParameter("Gain", 0, -1, 2).setUnits(Units.PERCENT_NORMALIZED);
+
+  public AudioStems(LX lx) {
+    super(lx, "audioStems");
+    if (current != null) {
path: audio-stems/src/main/java/titanicsend/audio/AudioStems.java
user: login: andrewlook
body: hmm, depending on how we're expecting people to use this, I might think about adopting a more deliberate singleton pattern here if we really want to prevent multiple instances of this from running. As it's written now, this code would still call the superclass' constructor `super(lx, "audioStems")` before throwing this exception.

What I'd consider doing instead:

```java

  private static AudioStems current;

  // Using 'synchronized' ensures that if there's a race condition / multiple simultaneous callers,
  // `current == null` will be true only once.
  public static synchronized AudioStems get(LX lx) {
    if (current == null) {
      current = new AudioStems(lx);
    }
    return current;
  }

  private AudioStems(LX lx) {
    super(lx, "audioStems");
    addParameter("gain", this.gain);
    loadConfig(lx);
    this.lx.engine.osc.addListener(this);
  }  
```
created_at: 2025-04-13T22:54:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,51 @@
+package titanicsend.audio;
+
+import heronarts.lx.LX;
+import heronarts.lx.LXPlugin;
+import heronarts.lx.studio.LXStudio;
+
+@LXPlugin.Name("Audio Stems")
+public class AudioStemsPlugin implements LXStudio.Plugin {
+
+  // This string must be manually updated to match the pom.xml version
+  private static final String VERSION = "0.1.2-SNAPSHOT";
+
+  private AudioStems audioStems;
+
+  public AudioStemsPlugin(LX lx) {
+    LOG.log("AudioStemsPlugin(LX) version: " + VERSION);
+  }
+
+  @Override
+  public void initialize(LX lx) {
+    lx.engine.registerComponent("audioStems", this.audioStems = new AudioStems(lx));
path: audio-stems/src/main/java/titanicsend/audio/AudioStemsPlugin.java
user: login: andrewlook
body: if we end up making AudioStems constructor private, `AudioStems.get(lx)` should work here
created_at: 2025-04-13T23:00:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,51 @@
+package titanicsend.audio;
+
+import heronarts.lx.LX;
+import heronarts.lx.LXPlugin;
+import heronarts.lx.studio.LXStudio;
+
+@LXPlugin.Name("Audio Stems")
+public class AudioStemsPlugin implements LXStudio.Plugin {
+
+  // This string must be manually updated to match the pom.xml version
+  private static final String VERSION = "0.1.2-SNAPSHOT";
path: audio-stems/src/main/java/titanicsend/audio/AudioStemsPlugin.java
user: login: andrewlook
body: (not a merge blocker, just wanted to call this out while it's fresh in my mind)

there's a streamlined way to do this - 

add this to the pom (the build time thing is optional):

```xml
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>build-helper-maven-plugin</artifactId>
    <version>3.3.0</version>
    <executions>
        <execution>
            <id>timestamp-property</id>
            <goals>
                <goal>timestamp-property</goal>
            </goals>
            <configuration>
                <name>build.time</name>
                <pattern>yyyy-MM-dd HH:mm</pattern>
                <locale>en_US</locale>
            </configuration>
        </execution>
    </executions>
</plugin>
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>templating-maven-plugin</artifactId>
    <version>1.0.0</version>
    <executions>
        <execution>
            <id>generate-version-class</id>
            <goals>
                <goal>filter-sources</goal>
            </goals>
        </execution>
    </executions>
</plugin>
```

then make a class called `Version`:

```java
package com.yourpackage;

public final class Version {
    private Version() {}
    
    public static final String VERSION = "${project.version}";
    public static final String BUILD_TIME = "${build.time}";
}
```

then you can just import that version class and use it like this:

```java
logger.info("Application version: {}", Version.VERSION);
```
created_at: 2025-04-13T23:02:21Z
author_association: COLLABORATOR
diff_hunk: @@ -120,6 +120,12 @@
             <groupId>com.jkbstudio</groupId>
             <artifactId>supermod</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>com.titanicsend</groupId>
+            <artifactId>audio-stems</artifactId>
+            <version>0.1.0-SNAPSHOT</version>
path: te-app/pom.xml
user: login: andrewlook
body: Often in multi-module maven setups I'd keep all the version numbers synchronized (so everything uses the same version number as the parent pom).

But, if we expect to be on separate release cycles with some of the modules (like maybe this plugin), we could still simplify the configuration a little bit - I'd consider adding a property to the root pom file, `{audio-stems.version}` (or something), then we can use that version number here and in `audio-stems/pom.xml`
created_at: 2025-04-13T23:07:44Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,203 @@
+package titanicsend.audio;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import heronarts.lx.LX;
+import heronarts.lx.LXComponent;
+import heronarts.lx.osc.LXOscListener;
+import heronarts.lx.osc.OscMessage;
+import heronarts.lx.parameter.BoundedFunctionalParameter;
+import heronarts.lx.parameter.BoundedParameter;
+import heronarts.lx.parameter.CompoundParameter;
+import heronarts.lx.parameter.LXParameter.Units;
+import heronarts.lx.parameter.ObjectParameter;
+import heronarts.lx.utils.LXUtils;
+import heronarts.lx.utils.ObservableList;
+import java.io.File;
+import java.io.FileReader;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/** Top-level component for Audio Stems, runs as a child of LX Engine */
+public class AudioStems extends LXComponent implements LXOscListener {
+
+  private static AudioStems current;
+
+  public static AudioStems get() {
+    return current;
+  }
+
+  private final ObservableList<Stem> mutableStems = new ObservableList<>();
+  public final ObservableList<Stem> stems = mutableStems.asUnmodifiableList();
+
+  private Stem[] selectorObjects = new Stem[] {null};
+  private String[] selectorOptions = new String[] {null};
+  private final List<Selector> selectors = new ArrayList<>();
+
+  public final CompoundParameter gain =
+      new CompoundParameter("Gain", 0, -1, 2).setUnits(Units.PERCENT_NORMALIZED);
+
+  public AudioStems(LX lx) {
+    super(lx, "audioStems");
+    if (current != null) {
path: audio-stems/src/main/java/titanicsend/audio/AudioStems.java
user: login: andrewlook
body: An alternative approach here could be that we could eliminate the `static AudioStems current` and `AudioStems get(LX lx)` from this class, and instead have `AudioStemsPlugin` maintain the singleton `AudioStems` instance that is accessed by code using the audio stems (e.g. `GLEngine`). I could see that being a little bit simpler, since it's a bit inconvenient to need to call `.get(lx)` (meaning that we'd need to have `lx` available anywhere we call this, just to ensure it gets constructed correctly).
created_at: 2025-04-13T23:35:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,51 @@
+package titanicsend.audio;
+
+import heronarts.lx.LX;
+import heronarts.lx.LXPlugin;
+import heronarts.lx.studio.LXStudio;
+
+@LXPlugin.Name("Audio Stems")
+public class AudioStemsPlugin implements LXStudio.Plugin {
+
+  // This string must be manually updated to match the pom.xml version
+  private static final String VERSION = "0.1.2-SNAPSHOT";
path: audio-stems/src/main/java/titanicsend/audio/AudioStemsPlugin.java
user: login: andrewlook
body: actually there's another way to do this with resource filtering, that's a bit easier: https://github.com/titanicsend/LXStudio-TE/pull/610/commits/b652b36493ad4b616496af404ac2453834f3aeaa
created_at: 2025-04-14T00:50:31Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.titanicsend</groupId>
+        <artifactId>titanicsend-parent</artifactId>
+        <version>0.2.3-SNAPSHOT</version>
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+
+    <artifactId>audio-stems</artifactId>
+    <version>0.1.0-SNAPSHOT</version>
+    <name>Audio Stems Plugin</name>
+    <description>A Chromatik plugin that receives audio stem information over OSC and makes it available for real-time display and mapping.</description>
+    <inceptionYear>2025</inceptionYear>
+
+    <properties>
+        <maven.compiler.source>21</maven.compiler.source>
+        <maven.compiler.target>21</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
path: audio-stems/pom.xml
user: login: jkbelcher
body: Sounds good, thanks.
created_at: 2025-04-14T03:27:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,51 @@
+package titanicsend.audio;
+
+import heronarts.lx.LX;
+import heronarts.lx.LXPlugin;
+import heronarts.lx.studio.LXStudio;
+
+@LXPlugin.Name("Audio Stems")
+public class AudioStemsPlugin implements LXStudio.Plugin {
+
+  // This string must be manually updated to match the pom.xml version
+  private static final String VERSION = "0.1.2-SNAPSHOT";
path: audio-stems/src/main/java/titanicsend/audio/AudioStemsPlugin.java
user: login: jkbelcher
body: Had been wondering about a way to do this, thanks!
created_at: 2025-04-14T03:31:40Z
author_association: COLLABORATOR
diff_hunk: @@ -120,6 +120,12 @@
             <groupId>com.jkbstudio</groupId>
             <artifactId>supermod</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>com.titanicsend</groupId>
+            <artifactId>audio-stems</artifactId>
+            <version>0.1.0-SNAPSHOT</version>
path: te-app/pom.xml
user: login: jkbelcher
body: Yes, potential separate release cycle.  And since we practically never update the TE App version, I want to be able to update this module version more frequently.
created_at: 2025-04-14T03:33:20Z
author_association: COLLABORATOR
diff_hunk: @@ -120,6 +120,12 @@
             <groupId>com.jkbstudio</groupId>
             <artifactId>supermod</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>com.titanicsend</groupId>
+            <artifactId>audio-stems</artifactId>
+            <version>0.1.0-SNAPSHOT</version>
path: te-app/pom.xml
user: login: jkbelcher
body: I actually started with a property in the parent pom, and maven throws some big warnings if you try to use a property in the <version> tag.  Something along the lines of "One of these days we're going to disallow this entirely because people keep screwing it up."
created_at: 2025-04-14T03:35:49Z
author_association: COLLABORATOR
diff_hunk: @@ -113,21 +74,25 @@ public String toString() {
 
   private final EMA ema = new EMA(0);
 
+  private double accumulator = 0.0;
+
   public AudioStemModulator() {
     this("Audio Stem");
   }
 
   public AudioStemModulator(String label) {
     super(label);
 
-    addParameter("stem", this.stem);
+    addParameter(
+        "stem",
+        this.stem =
+            AudioStems.get()
+                .newSelector("Stem", "Which audio stem is the source for this modulator"));
path: audio-stems/src/main/java/titanicsend/audio/AudioStemModulator.java
user: login: jkbelcher
body: This kind of junk makes me grumble about the linter.  But yes I'll take your suggestion. [8c04e158](https://github.com/titanicsend/LXStudio-TE/pull/609/commits/8c04e15899b6148ad25b557ffc738fd92b0264c5)
created_at: 2025-04-14T03:39:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,203 @@
+package titanicsend.audio;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import heronarts.lx.LX;
+import heronarts.lx.LXComponent;
+import heronarts.lx.osc.LXOscListener;
+import heronarts.lx.osc.OscMessage;
+import heronarts.lx.parameter.BoundedFunctionalParameter;
+import heronarts.lx.parameter.BoundedParameter;
+import heronarts.lx.parameter.CompoundParameter;
+import heronarts.lx.parameter.LXParameter.Units;
+import heronarts.lx.parameter.ObjectParameter;
+import heronarts.lx.utils.LXUtils;
+import heronarts.lx.utils.ObservableList;
+import java.io.File;
+import java.io.FileReader;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/** Top-level component for Audio Stems, runs as a child of LX Engine */
+public class AudioStems extends LXComponent implements LXOscListener {
+
+  private static AudioStems current;
+
+  public static AudioStems get() {
+    return current;
+  }
+
+  private final ObservableList<Stem> mutableStems = new ObservableList<>();
+  public final ObservableList<Stem> stems = mutableStems.asUnmodifiableList();
+
+  private Stem[] selectorObjects = new Stem[] {null};
+  private String[] selectorOptions = new String[] {null};
+  private final List<Selector> selectors = new ArrayList<>();
+
+  public final CompoundParameter gain =
+      new CompoundParameter("Gain", 0, -1, 2).setUnits(Units.PERCENT_NORMALIZED);
+
+  public AudioStems(LX lx) {
+    super(lx, "audioStems");
+    if (current != null) {
path: audio-stems/src/main/java/titanicsend/audio/AudioStems.java
user: login: jkbelcher
body: Based partially on LX convention and partially my own opinion I'm declining the changes on this one.  Explanations are on your example commit.
Not too worried about calling the super constructor before throwing an exception.  The super constructor itself will throw an exception if the same component key is attempted to be registered twice.  Either way, calling this twice will crash something (possibly the entire app) and make the module not functional.  I'd rather do the safety check once in the constructor and get it out of the way, rather than inline with every call to `get()`.
And yes, definitely wouldn't want to pass the LX instance to every `get()` call just so it could be used once for initialization.
created_at: 2025-04-14T03:51:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,51 @@
+package titanicsend.audio;
+
+import heronarts.lx.LX;
+import heronarts.lx.LXPlugin;
+import heronarts.lx.studio.LXStudio;
+
+@LXPlugin.Name("Audio Stems")
+public class AudioStemsPlugin implements LXStudio.Plugin {
+
+  // This string must be manually updated to match the pom.xml version
+  private static final String VERSION = "0.1.2-SNAPSHOT";
+
+  private AudioStems audioStems;
+
+  public AudioStemsPlugin(LX lx) {
+    LOG.log("AudioStemsPlugin(LX) version: " + VERSION);
+  }
+
+  @Override
+  public void initialize(LX lx) {
+    lx.engine.registerComponent("audioStems", this.audioStems = new AudioStems(lx));
path: audio-stems/src/main/java/titanicsend/audio/AudioStemsPlugin.java
user: login: jkbelcher
body: Went a different route with this.
created_at: 2025-04-14T04:38:06Z
author_association: COLLABORATOR

PR comments:
user: login: jkbelcher
created_at: 2025-04-13T16:12:15Z
body: Recommend to NOT squash merge, to preserve history on files that were `git mv`'d and then modified
author_association: COLLABORATOR
user: login: jkbelcher
created_at: 2025-04-14T03:56:24Z
body: > There are probably a few different ways to approach encapsulating this safely, but it might be smart to think through how integrating applications will be accessing this.

Fortunately I already worked through these details exactly by developing it as a standalone plugin.  It's dialed in to work both as 1) a compiled JAR imported from the ~/Chromatik/Packages folder, and 2) as a sub-plugin like we're doing here.
author_association: COLLABORATOR