Coding with LX
LX is a simple but powerful framework for developing real-time, point-based animations. All of the core engine code used by Chromatik is contained in the LX library. The system is built around a few core concepts.
Complete Javadoc API documentation is available and a large portion of the source code is available for reference in the Public Repositories.
Components
The LXComponent base class acts as a modular container for organizing program state. Nearly everything encountered in Chromatik corresponds to a component (e.g. patterns, effects, modulators, mixer channels, fixtures, snapshots, etc.)
Components are organized in a tree hierarchy with one-to-many parent-child relationships. All components have a unique path that allows for consistent routing of modulation and OSC messages.
class MyComponent extends LXComponent {
public MyComponent(LX lx) {
super(lx);
}
}
Registering Parameters
The primary function of components is to act as a container for Parameters, registered using the addParameter
method.
If the parameter is of a listenable type, the component automatically registers itself as a listener to the parameter. The specified path
is used to uniquely identify the parameter in the program as well as via external OSC messages.
class MyComponent extends LXComponent {
public final BooleanParamter active =
new BooleanParamter("Active", false)
.setDescription("Whether component is active");
public final CompoundParameter amount =
new CompoundParameter("Amount", 0)
.setUnits(CompoundParameter.Units.PERCENT_NORMALIZED)
.setDescription("Whether component is active");
public MyComponent(LX lx) {
super(lx);
addParameter("active", this.active);
addParameter("amount", this.amount);
}
public void onParameterChanged(LXParameter p) {
super.onParameterChanged(p);
// Will fire automatically for registered parameters
}
}
Registering Children
Child components may be added at a path, similar to parameters. Child and parameter path names must not conflict.
class MyComponent extends LXComponent {
private final OtherComponent other;
public MyComponent(LX lx) {
super(lx);
this.other = new OtherComponent(lx);
addChild("other", this.other);
}
Arrays of children may be registered using addArray
.
class MyComponent extends LXComponent {
private final List<OtherComponent> others = new ArrayList<>();
public MyComponent(LX lx) {
super(lx);
addArray("other", this.others);
}
...
Saving / Loading
Components are saved and restored via the LXSerializable interface. You do not need to add any custom implementation to your components so long as all parameters and child components are properly registered. You may override these methods if there is state that cannot be adequately represented by parameters.
Storing arrays
Registered arrays are not automatically saved and restored, as there is too much ambiguity around how to instantiate them (they may be of different subtypes or need additional constructor arguments), how to manage/check state consistency as the list grows, and what to do in the case of partial failure.
Below is example code for basic handling of arrays.
private final List<Thing> things = new ArrayList<>();
// Assume prior call addArray("thing", this.things);
private statc final String KEY_THINGS = "things";
public void save(LX lx, JsonObject object) {
super.save(lx, object);
object.add(KEY_THINGS, LXSerializable.Utils.toArray(lx, this.things));
}
public void load(LX lx, JsonObject object) {
super.load(lx, object);
if (object.has(KEY_THINGS)) {
JsonArray thingsArray = object.getAsJsonArray(KEY_THINGS);
for (JsonElement thingElement : thingsArray) {
JsonObject thingObj = (JsonObject) thingElement;
// Construct appropriately based upon thingObj
Thing thing = new Thing(lx);
thing.load(lx, thingObj);
this.things.add(thing);
}
}
}
Marker Interfaces
OSC
A component's parameters and children are made available via the OSC system via the LXOscComponent marker interface. You do not need to add any custom OSC implementation to your components.
// Flag this component as supporting OSC
class MyComponent extends LXComponent implements LXOscComponent {
Renaming
If a component is user-renamable, it can be marked as such.
// Flag this component as user-renameable
class MyComponent extends LXComponent implements LXComponent.Renamable {
Parameters
The LXParameter interface can be thought of as a smart variable. Like a variable, it holds a value which can be set and retrieved. But an LXParameter has important additional features:
- A unique path that identifies this parameter in the larger program structure
- A label, description, and options that specify how it should function in a UI
- The ability to notify registered listeners to changes in value (LXListenableParameter)
- A well-defined bounded range (LXNormalizedParameter)
- The ability to be easily mapped to UI components, MIDI controllers, or OSC messages
The following sections describe the most important parameter types and the main ways of working with parameters.
Core Parameter Types
BoundedParameter
The BoundedParameter is a common and basic type of parameter which has a double-precision floating point value and a designated range.
// Create a parameter with initial value of 50 and range from 0-100
BoundedParameter parameter = new BoundedParameter("Label", 50, 0, 100);
// All parameter types have a description field
parameter.setDescription("A helpful description that tells the user what this parameter does");
// Values may be set and retrieved either in absolute range...
parameter.getValue();
parameter.setValue(100);
// ...or in a normalized range, from 0-1
parameter.getNormalized();
parameter.setNormalized(.4);
BooleanParameter
The BooleanParameter is a very basic parameter which stores a true or false value. They have a special property that specifies how they are expected to behave in a UI when represented as a button or switch.
BooleanParameter parameter = new BooleanParameter("Bool", false);
// The parameter flips between true and false state
parameter.setMode(BooleanParameter.Mode.TOGGLE);
// The parameter is true when actively engaged
parameter.setMode(BooleanParameter.Mode.MOMENTARY);
TriggerParameter
The TriggerParameter is a momentary boolean parameter which automatically sets its value back to false
whenever triggered, and may register an explicit to occur on this action. These are typically used to represent discrete actions occuring in the program rather than a binary state.
TriggerParameter trig = new TriggerParameter("Trig");
// Fire the trigger
trig.trigger();
// A Runnable may be supplied to fire anytime the trigger engages
TriggerParameter trig = new TriggerParameter("Trig", () -> {
System.out.println("Trig fired!");
});
DiscreteParameter
A DiscreteParameter has a bounded range, but values are discrete integers.
// Discrete parameter with an initial value of 4 and range from [0,7]
DiscreteParameter parameter = new DiscreteParameter("Discrete", 4, 8);
// Values may be retrieved as integers
parameter.getValuei();
ObjectParameter
An ObjectParameter is a discrete parameter which selects from a fixed list of objects.
// Discrete parameter with an initial value of 4 and range from [0,7]
final Thing[] things = { thing1, thing2, thing3, ... };
ObjectParameter<Thing> parameter = new ObjectParameter<Thing>("Thing", things);
// Value may be retrieved as an object
Thing thing = parameter.getObject();
EnumParameter
An EnumParameter is a discrete parameter representing the values of an enum
.
enum MyEnum {
ONE,
TWO,
THREE,
...
}
EnumParameter<MyEnum> parameter = new EnumParameter<MyEnum>("Enum", MyEnum.ONE);
// Value may be retrieved as an enum
MyEnum e = parameter.getEnum();
CompoundParameter
Perhaps the most important parameter type, the CompoundParameter is very similar to a BoundedParameter, with the special ability to have its value automated by a Modulator. The name of the class refers to the parameter's ability to combine values in this way. When writing a pattern, you should almost always use CompoundParameter instead of BoundedParameter, as this opens up interesting modulation possibilities.
// Syntax looks just like other bounded parameters
CompoundParameter parameter = new CompoundParameter("Label", 50, 0, 100);
There are also CompoundDiscreteParameter, CompoundObjectParameter, and CompoundEnumParameter for modulated parameters with discrete values.
FunctionalParameter
The FunctionalParameter may be used in situations where a parameter needs to compute its value dynamically. In this situation, you should take care to note that the computation is not cached and will be performed every time the parameter's value is requested.
FunctionalParameter parameter = new FunctionalParameter() {
public double getValue() {
// Compute the parameter value dynamically
}
};
// Shorthand syntactic sugar
FunctionalParameter parameter = FunctionalParameter.create("Label", () -> {
return value;
});
Listening to Parameters
Any parameter which implements the LXListenableParameter interface may be easily registered for callbacks. Callbacks are only triggered when the value actually changes. A special method may be used to force notification.
BoundedParameter parameter = new BoundedParameter("test", 0);
parameter.addListener(new LXParameterListener() {
public void onParameterChanged(LXParameter p) {
// Take action based upon change to p
}
});
// Better as shorthand
parameter.addListener(p -> {
// Take action based upon change to p
});
// Parameter is given a new value, listener is triggered
parameter.setValue(1.);
// Parameter already has this value, listener not triggered
parameter.setValue(1.);
// Listener is explicitly triggered, even though no change in value
parameter.bang();
Registering Parameters
Parameters should be registered with the component they belong to in the component's constructor. A path is specified, unique to the scope of this component. A parameter may not be registered to more than one component.
public class MyPattern extends LXPattern {
public final CompoundParameter value =
new CompoundParameter("Val", 5, 0, 10)
.setDescription("An arbitrary value used by this pattern.");
public MyPattern(LX lx) {
super(lx);
addParameter("value", this.value);
}
public void onParameterChanged(LXParameter p) {
super.onParameterChanged(p);
if (p == this.value) {
// Listenable parameters are automatically registered
}
}
public void run(double deltaMs) {
// Retrieve the current (potentially modulated) value
final float value = this.value.getValuef();
}
...
}
Specifying Parameter UI
The key advantages of using parameters to hold program state is that LX Studio can automatically create UI for them, display their values in human-readable fashion, store them in project files, and map them to MIDI controllers or OSC messages.
Description
// All parameters should have a helpful description set to educate the user
parameter.setDescription("Useful text that explains the parameter");
Units
// Customize display of the value in common units
parameter.setUnits(LXParameter.Units.MILLISECONDS);
parameter.setUnits(LXParameter.Units.DECIBELS);
parameter.setUnits(LXParameter.Units.HERTZ);
Polarity
// A knob for this parameter fills itself up from low to hi (e.g. Volume)
parameter.setPolarity(LXParameter.Polarity.UNIPOLAR);
// A knob for this parameter draws with a center point (e.g. Left/Right)
parameter.setPolarity(LXParameter.Polarity.BIPOLAR);
Formatter
A default formatter is put in place based upon the Units setting. If you require custom text formatting you may provide this explicitly.
// Use a custom formatter to display the parameter's value
parameter.setFormatter(new LXParameter.Formatter() {
public String format(double value) {
// Render the value for the UI
}
});
// Or in shorthand
parameter.setFormatter(value -> {
// Return value as string for the UI
});
Modulators
One layer above LXParameter is LXModulator. A modulator is a specialized type of parameter with a value that may automatically change over time. Modulators are runnable components, which means that their operation can be started and stopped. A modulator may also have registered parameters that control its own operation.
Note: modulators may also be instantiated by the user via the Chromatik UI. This is covered in the Modulation user guide and does not require custom code. This section details how to use algorithmic modulators behind-the-scenes in custom code.
Example: LFO modulators
The most basic types of modulators are the LFO family. LFO denotes Low Frequency Oscillation, a common concept in audio synthesis which is also extremely useful in animation.
// A sinusoidal wave that oscillates from 0 to 1 every 2500 milliseconds
SinLFO lfo = new SinLFO("Sin Wave", 0, 1, 2500);
// A triangular wave oscillating from 2 to 5 every 5000 milliseconds
TriangleLFO lfo = new TriangleLFO("Tri Wave", 2, 5, 5000);
// A square wave that switches between -4 and 4 every 1000 milliseconds
SquareLFO lfo = new TriangleLFO("Square Wave", -4, 4, 1000);
// A sawtooth wave that ramps from 5 to 15 every 3000 milliseconds
SawLFO lfo = new SawLFO("Saw Wave", 5, 15, 3000);
Using Modulators
Registration
Modulators must belong to an LXModulatorComponent. They should be registered in the component's constructor.
public class MyPattern extends LXPattern {
public final SinLFO lfo = new SinLFO("Oscillation", 0, 1, 2500);
public MyPattern(LX lx) {
super(lx);
// Register the modulator
addModulator(lfo);
// Start the modulator running
lfo.start();
}
public void run(double deltaMs) {
// Retrieve the current LFO value
final float lfo = this.lfo.getValuef();
}
...
}
Alternately use the shorthand startModulator(lfo)
to add and start a modulator in one step.
Starting and Stopping
Modulators have a set of methods that control their operation.
// Starts the modulator
modulator.start();
// Stops the modulator
modulator.stop();
// Stops the modulator and resets to initial condition
modulator.reset();
// Re-starts the modulator from initial condition
modulator.trigger();