Lighting
Light sources, fixtures and control protocols for working with physical and rendered lighting.
8 source file(s), 206 public symbol(s), 206 of them carrying KDoc. Everything below is the documentation that lives beside the code — the same text the completion documentation panel shows in the editor. A blank summary means the declaration has no KDoc yet, not that this page dropped it.
ArtNet.kt
Art-Net 4 packet encoding and decoding — the wire format only, no sockets.
Art-Net (Artistic Licence, "Art-Net 4 Specification", protocol version 14) carries DMX512 over
UDP port 6454 (0x1936). Every packet starts with the 8-byte id "Art-Net\0" followed by a
16-bit OpCode sent low byte first, as is the Port field of an ArtPollReply; the ArtDmx slot
count, and every other multi-byte field here, is sent high byte first. This file implements the
four packets Field uses:
- ArtDmx (
0x5000) — one universe of slot data, 2..512 bytes, even length. - ArtSync (
0x5200) — "latch the frames you have buffered now", for tear-free multi-universe output. - ArtPoll (
0x2000) / ArtPollReply (0x2100) — discovery. Field answers ArtPoll as a node when it is receiving (seeDmxInput).
Encoders write into a caller-owned ByteArray and return the number of bytes written, so a
sender can reuse one buffer per universe for its whole life; nothing on the send path allocates.
Decoders return small immutable values, or null for a packet that is not Art-Net or not one of
these OpCodes.
PortAddress is the 15-bit universe number those packets carry, split Net : SubNet : Universe.
DmxOutput and DmxInput are built on these codecs; a box reaches them through artnetOut and
artnetIn. The user-facing page is
Driving Lights: Art-Net and sACN.
src/jvmMain/kotlin/afield/io/dmx/ArtNet.kt — read the source
ArtNet
The Art-Net codecs: four encoders, their decoders, and the OpCodes, packet lengths and node fields they use.
| Declaration | Summary |
|---|---|
const val PORT |
UDP port every Art-Net packet is sent to and received on: 6454 (0x1936). |
const val PROTOCOL_VERSION |
Protocol revision this implementation writes into ProtVerHi/Lo: 14. |
const val OP_POLL |
ArtPoll OpCode, 0x2000: "which nodes are out there". A DmxInput answers one with an ArtPollReply unless answerPolls is off. |
const val OP_POLL_REPLY |
ArtPollReply OpCode, 0x2100: a node describing itself, in answer to an ArtPoll. |
const val OP_DMX |
ArtDmx OpCode, 0x5000: one universe of slot data. |
const val OP_SYNC |
ArtSync OpCode, 0x5200: output the ArtDmx frames you are holding, now. Art-Net 4 nodes buffer frames once they have seen one of these and drop back to immediate output after four seconds without one. |
const val DMX_HEADER |
Size of the ArtDmx header before the slot data. |
const val DMX_MAX_PACKET |
Largest ArtDmx packet: header plus 512 slots. |
const val SYNC_LENGTH |
ArtSync is fixed length. |
const val POLL_LENGTH |
Art-Net 4 ArtPoll, including the targeted-mode and ESTA/OEM fields. |
const val POLL_REPLY_LENGTH |
Art-Net 4 ArtPollReply, including the trailing filler. |
fun encodeDmx(buf: ByteArray, portAddress: PortAddress, data: ByteArray, length: Int = 512, sequence: Int = 0, physical: Int = 0, dataOffset: Int = 0): Int |
Encode an ArtDmx packet into buf and return its length. |
fun encodeSync(buf: ByteArray): Int |
Encode an ArtSync packet into buf (14 bytes) and return its length. |
fun encodePoll(buf: ByteArray, flags: Int = 0x02, diagPriority: Int = 0x10): Int |
Encode an Art-Net 4 ArtPoll into buf (22 bytes) and return its length. |
fun encodePollReply(buf: ByteArray, node: NodeInfo): Int |
Encode an ArtPollReply (239 bytes) describing a node and return its length. |
fun isArtNet(buf: ByteArray, length: Int = buf.size): Boolean |
True if buf starts with the Art-Net id; says nothing about the OpCode. |
fun opCode(buf: ByteArray, length: Int = buf.size): Int |
The OpCode of an Art-Net packet, or -1 if buf is not one. |
fun decodeDmx(buf: ByteArray, length: Int = buf.size): Dmx? |
Decode an ArtDmx packet. The returned Dmx.data is a fresh copy of exactly length slots; use decodeDmxInto on a hot receive path to avoid the copy. |
fun decodeDmxInto(buf: ByteArray, length: Int, into: ByteArray): Int |
Decode just the header of an ArtDmx packet and copy its slots into into; returns the port address as its raw 15-bit int, or -1 if this is not a well-formed ArtDmx. The slot count is (buf[16] shl 8) or buf[17]. |
fun decodePoll(buf: ByteArray, length: Int = buf.size): Poll? |
Decode an ArtPoll (accepts the 14-byte pre-Art-Net-4 form as well as the 22-byte one). |
fun decodePollReply(buf: ByteArray, length: Int = buf.size): PollReply? |
Decode an ArtPollReply. Accepts the shorter replies older nodes send (>= 207 bytes). |
class Dmx(val sequence: Int, val physical: Int, val portAddress: PortAddress, val data: ByteArray) |
A decoded ArtDmx. |
data class Poll(val protocolVersion: Int, val flags: Int, val diagPriority: Int) |
A decoded ArtPoll. |
class PollReply(val ip: ByteArray, val port: Int, val shortName: String, val longName: String, val report: String, val portTypes: List<Int>, val outputs: List<PortAddress>, val style: Int, val bindIndex: Int) |
A decoded ArtPollReply: who a node is and which universes it outputs. |
data class NodePort(val universe: Int, val output: Boolean = true, val input: Boolean = false) |
One advertised port of a node. universe is the low 4 bits of the port-address. |
data class NodeInfo(val shortName: String = "Field4", val longName: String = "Field4 live-coding environment (afield.io.dmx)", val report: String = "#0001 [0000] Field4 ok", val ip: ByteArray = byteArrayOf(127, 0, 0, 1), val mac: ByteArray = ByteArray(6), val net: Int = 0, val subNet: Int = 0, val ports: List<NodePort> = listOf(NodePort(0)), val style: Int = 0, val bindIndex: Int = 1) |
What Field says about itself in an ArtPollReply. |
PortAddress
value class PortAddress(val value: Int)
An Art-Net 4 port-address: the 15-bit universe number, split Net (7 bits) : SubNet (4) : Universe (4).
| Declaration | Summary |
|---|---|
val net: Int |
Bits 14..8. |
val subNet: Int |
Bits 7..4. |
val universe: Int |
Bits 3..0. |
val subUni: Int |
The SubUni byte of ArtDmx: SubNet in the high nibble, Universe in the low. |
override fun toString() |
Net:SubNet:Universe in decimal, the way Art-Net addresses are written: universe 1 is 0:0:1. |
DmxBox.kt
Box-scoped ownership of DMX sockets.
Live coding re-runs a box many times and stops it at will; a socket must survive the first and
die with the second. So each artnetOut / sacnOut / artnetIn / sacnIn call is keyed by
box and endpoint: a re-run with the same arguments gets the same, still-sending object back (no
rebind, no gap on the wire), different arguments close the old one first, and stopping the box
closes it through Box.registerStopHook. Nothing is cached per port for the life of the process,
so a rig stops when the box driving it stops.
Inputs are keyed by protocol and port only, because only one socket can own a UDP port: a second box asking for the same input shares the first one, and stopping either box closes it (the next call reopens it).
artPoll is the one call here that owns nothing. It sends a single ArtPoll and gathers the
replies for a timeout, which is how to find the nodes on a network before patching to them. The
user-facing page is Driving Lights: Art-Net and sACN.
src/jvmMain/kotlin/afield/io/dmx/DmxBox.kt — read the source
Used in: Driving Lights: Art-Net and sACN
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.artnetOut(host: String = "127.0.0.1", port: Int = ArtNet.PORT, maxRateHz: Double = 44.0, sync: Boolean = false): DmxOutput |
An Art-Net output owned by this box: sends to host (a node's IP, 127.0.0.1 for a local receiver, or a broadcast address such as 2.255.255.255) on port. Re-running the box returns the same output; stopping it closes the socket. See DmxOutput for the send policy. |
fun Box.sacnOut(host: String? = null, port: Int = Sacn.PORT, sourceName: String = "Field4 ${name.ifBlank { id }}", priority: Int = Sacn.DEFAULT_PRIORITY, maxRateHz: Double = 44.0, sync: Boolean = false): DmxOutput |
An sACN (E1.31) output owned by this box. With host null, each universe goes to its multicast group 239.255.hi.lo; with a host, unicast. Stopping the box sends Stream_Terminated and closes the socket. The CID is derived from the box id, so a re-created output is recognised by receivers as the same source. |
fun Box.artnetIn(port: Int = ArtNet.PORT, merge: MergeMode = MergeMode.HTP): DmxInput |
An Art-Net input on port: Field becomes a node, merges what it receives (merge) and answers ArtPoll. Closed when this box stops. |
fun Box.sacnIn(port: Int = Sacn.PORT, universes: List<Int> = emptyList(), merge: MergeMode = MergeMode.HTP): DmxInput |
An sACN input on port. Pass universes to join their multicast groups; unicast traffic arrives regardless. Closed when this box stops. |
fun artPoll(host: String = "255.255.255.255", port: Int = ArtNet.PORT, timeoutMs: Long = 1000): List<ArtNet.PollReply> |
Art-Net discovery: send one ArtPoll to host:port and collect the ArtPollReplies that come back within timeoutMs. Blocks for the whole timeout. Use a broadcast address (e.g. 255.255.255.255 or your subnet's) to find every node on a network; 127.0.0.1 finds a Field DmxInput on this machine. |
DmxSockets
The registry behind the box-scoped accessors: one DmxOutput per box and endpoint, one DmxInput per protocol and port. open lists what is live; closeAll shuts all of it.
| Declaration | Summary |
|---|---|
val open: List<AutoCloseable> |
Every open output and input, for a status display. |
fun closeAll() |
Close everything, e.g. from a "panic" box. |
DmxInput.kt
DMX reception: Field as an Art-Net or sACN node on the network.
DmxInput binds a UDP port, keeps the most recent frame from every source on every universe, and
merges them on demand. MergeMode chooses highest-takes-precedence or latest-takes-precedence,
under sACN's priority rule, its sequence-number rule (SacnSequence) and both protocols' source
timeouts. universe(n) returns the merged 512 slots, channel(u, c) a single slot, and
version(n) a counter that moves whenever a universe does, which is how a loop or a test waits
for the next frame without sleeping for a guessed interval.
Field also answers the discovery traffic it hears: an ArtPoll gets back an ArtPollReply
describing ArtNet.NodeInfo, so consoles and discovery tools list Field alongside real nodes,
and sACN universe-discovery pages from other sources are kept rather than dropped.
What arrived is slots, not colours; Fixture decode reads a fixture's look back out of them in
its own profile, which is what makes a round trip measurable (SceneOnARig). In a box, use
artnetIn or sacnIn, which close the socket when the box stops. The user-facing page is
Driving Lights: Art-Net and sACN.
src/jvmMain/kotlin/afield/io/dmx/DmxInput.kt — read the source
Used in: Driving Lights: Art-Net and sACN
MergeMode
How a DmxInput combines several sources sending the same universe.
Constants: HTP, LTP.
DmxInput
A DMX receiver: Field as an Art-Net or sACN node.
| Declaration | Summary |
|---|---|
val protocol: DmxProtocol |
Which wire format is understood. Packets of the other protocol arriving on this port are counted in packetsDiscarded rather than decoded. It also sets the default port and how long a silent source is kept: 2.5 s for sACN, 10 s for Art-Net. |
val port: Int |
UDP port to bind: 6454 for Art-Net, 5568 for sACN. Pass 0 to let the system pick a free one and read localPort back, which is how a sender and a receiver run side by side in one process without claiming a port something else may hold. |
val merge: MergeMode |
How several live sources on one universe are combined. Under sACN this applies within the highest priority live on that universe, since lower-priority sources do not count at all. |
val multicastUniverses: List<Int> |
Universes whose sACN multicast group the socket joins, which is what it takes to hear multicast traffic at all. A sender unicasting to this machine needs none of them, and Art-Net ignores the list. |
val node: ArtNet.NodeInfo |
What Field says about itself when it answers an ArtPoll. The net, subnet and port list of the reply are replaced by the universes actually arriving, so what this carries to the wire is the identity: the short and long name, the status report string, the advertised IP and MAC. |
val answerPolls: Boolean |
Answer an ArtPoll with an ArtPollReply, so consoles and discovery tools list Field as a node. Art-Net only; turn it off to listen without advertising. |
val acceptPreview: Boolean |
Accept sACN data whose preview bit is set. Preview data is meant for visualisers rather than for driving lights, so by default it is counted in packetsDiscarded and otherwise ignored. |
class Source(val key: String, val name: String, val universe: Int) |
What is known about one source on one universe. |
val pollReplies |
ArtPollReplies seen by this input (nodes that answered someone's ArtPoll), keyed by IP. |
val discoveredSources |
sACN universe-discovery pages seen, keyed by source CID. |
var isClosed |
True once close has run. The receive thread stops at the next loop and the socket is released; a closed input does not reopen. |
val localPort: Int |
The UDP port this input is actually bound to: port, unless that was 0, in which case the system chose a free one — the way to run a sender and a receiver side by side without picking a number that something else on the machine may already hold. |
var lastError: Throwable? |
The last receive failure, or null. |
val packetsReceived: Long |
Datagrams the socket handed over, of any kind, decoded or not. |
val packetsDiscarded: Long |
Packets that arrived and were not used: a malformed or truncated one, an OpCode or vector this input does not handle, an sACN sequence number the E1.31 §6.7.2 rule rejects, or preview data with acceptPreview off. Every datagram counts in packetsReceived first, so this rising one for one with that usually means the other protocol is on the port. |
var pollsAnswered |
ArtPollReplies this input has sent in answer to ArtPolls. |
fun handle(buf: ByteArray, length: Int, from: InetAddress, fromPort: Int) |
Process one datagram as if it had arrived from from:fromPort. The receive thread calls this; it is public so tests can feed packets without a socket. |
fun sources(universe: Int, nowNanos: Long = System.nanoTime()): List<Source> |
Sources currently live on universe (timed-out ones are pruned here). |
val universes: List<Int> |
Every universe any live source has sent, ascending. |
fun universe(universe: Int, into: ByteArray = ByteArray(512)): ByteArray |
The merged 512 slots of universe (a fresh array; all zeros if nothing is live). Use into to reuse a buffer on a per-frame path. |
fun channel(universe: Int, channel: Int): Int |
Value of 1-based channel of the merged universe, 0..255. |
fun version(universe: Int): Long |
Increments whenever universe's merged data may have changed; cheap to poll per frame. |
fun onFrame(listener: (universe: Int, data: ByteArray) -> Unit) |
Call listener with (universe, merged slots) on the receive thread after every accepted data packet. Keep it short; hand work to the main loop if it touches Field state. |
override fun close() |
Stop receiving and release the socket. Idempotent. |
override fun toString() |
The protocol and port, the merge mode, the universes with a live source right now, and packetsReceived. An empty universe list with a rising count is the signature of traffic that arrives and is not accepted. The port shown is the one asked for, so it reads 0 when the system chose one; localPort has the number actually bound. |
DmxOutput.kt
DMX transmission: the frame buffers a box writes into, and the thread that puts them on the wire.
DmxUniverse is one universe of 512 slots, addressed 1-based as consoles and fixture manuals
address them. DmxOutput owns a set of those and a daemon sender thread that ticks at up to
44 Hz, sending a universe whose bytes have changed and re-sending an unchanged one often enough
that no receiver times it out. DmxProtocol picks the wire format, encoded by ArtNet or
Sacn: Art-Net needs a destination host, and sACN goes to each universe's multicast group
unless one is given.
Nothing here waits on Field's frame clock. beforeTick runs at the top of each sender tick,
before the universes are snapshotted, which is where Patch animate computes the next look, so
a rig keeps running whether or not a renderer is ticking.
In a box, reach an output through artnetOut or sacnOut rather than this constructor: those
hand back the same output across re-runs and close it when the box stops. The user-facing page is
Driving Lights: Art-Net and sACN.
src/jvmMain/kotlin/afield/io/dmx/DmxOutput.kt — read the source
Used in: A RectSL scene on a light rig, Driving Lights: Art-Net and sACN
DmxProtocol
Which wire protocol a DmxOutput speaks or a DmxInput listens for.
Constants: ARTNET, SACN.
DmxUniverse
class DmxUniverse(val number: Int) : afield.ui.visualization.ToHTML
One universe of DMX data: 512 slots, written by Field code, read by a DmxOutput's sender thread.
| Declaration | Summary |
|---|---|
val data |
The raw slots, 0-based. Writing here directly is fine; the sender copies it each tick. |
var length |
How many slots are transmitted; 512 unless a rig wants shorter packets. |
operator fun get(channel: Int): Int |
Value of 1-based channel, 0..255. |
operator fun set(channel: Int, value: Int) |
Set 1-based channel to value, clamped to 0..255. |
fun setLevel(channel: Int, level: Double) |
Set 1-based channel from a 0..1 level. |
fun set16(channel: Int, value: Int) |
Set a 16-bit value (0..65535) into channel (coarse) and channel + 1 (fine). |
fun clear() |
Zero every slot. |
override fun toHTML(): String |
Evaluating a universe in a box draws its slots as a 32 × 16 grid of gray cells, see universeToFLines. |
override fun toString() |
The universe number and its first twelve slot values, or all of them if length is shorter. |
DmxOutput
A DMX transmitter: a set of DmxUniverse frame buffers and a thread that sends them as Art-Net or sACN.
| Declaration | Summary |
|---|---|
val protocol: DmxProtocol |
Which wire format goes out. It also decides the default port and whether host is required. |
val host: String? |
Where packets are sent. Art-Net needs one — a node's IP, or a broadcast address — and the constructor refuses to build without it. sACN unicasts to it when it is given and otherwise sends to each universe's multicast group. |
val port: Int |
UDP port to send to: 6454 for Art-Net, 5568 for sACN. |
val sourceName: String |
The name this source announces in sACN data and discovery packets, at most 63 bytes of UTF-8 in a 64-byte field. ArtDmx has no source-name field, so Art-Net ignores it. |
val priority: Int |
sACN priority, 0..200. A receiver uses only the highest priority live on a universe and merges within it, so raising this takes a universe over from a lower-priority source. Art-Net has no equivalent field. |
val cid: UUID |
This source's sACN component identifier, a fresh UUID per output. It is how a receiver tells two sources on one universe apart, so an output that is closed and rebuilt arrives as a new source rather than as a continuation of the old one. |
val maxRateHz: Double |
Sender ticks per second, at most 1000. A tick sends the universes that changed, so this is the ceiling on how often one universe goes out rather than a rate it is held to: an unchanging rig sends only keep-alives. 44 Hz is the DMX512 full-frame rate both protocols treat as the limit. |
val keepAliveMs: Long |
How long an unchanged universe may go unsent before it is re-sent. The default of 900 ms sits inside E1.31's 800–1000 ms keep-alive window with a tick period to spare, and well inside both protocols' data-loss timeouts. |
val sync: Boolean |
End every tick that sent anything with one synchronization packet, so a rig spread over several universes latches in one step instead of tearing across them. |
val syncUniverse: Int |
The sACN synchronization address: carried by data packets and used for the synchronization packet when sync is on. Art-Net's ArtSync addresses nothing, so Art-Net ignores it. |
val discovery: Boolean |
Send sACN universe-discovery pages every 10 seconds listing the universes this output has been asked for. They go to one well-known universe, so a tool learns what this source transmits without joining the multicast group of every universe it might be on. On by default for sACN, and unused for Art-Net, where discovery runs the other way round as ArtPoll. |
var isClosed |
True once close has run. A closed output does not restart: start returns, tick sends nothing, and the socket is gone. |
val packetsSent: Long |
Data packets sent so far (sync and discovery packets not counted). |
var lastError: Throwable? |
The last send failure, or null. Sends never throw into Field code; look here instead. |
var beforeTick: ((seconds: Double) -> Unit)? |
Called with seconds since this output was created at the start of every sender tick, on the sender thread, before the universes are snapshotted — the place to compute the next look, so lights animate on the DMX clock whether or not anything else in Field is ticking. There is one slot, not a list: re-running a box replaces the animation instead of stacking a second one. Exceptions are caught into lastError. See animate and Patch.animate. |
fun animate(fn: (seconds: Double) -> Unit) |
Set beforeTick: run fn once per sender tick, with seconds since the output started. |
fun universe(number: Int): DmxUniverse |
The frame buffer for number, created (all zeros) on first use. |
val universes: List<Int> |
The universes this output has been asked for, ascending. |
fun <T> frame(block: DmxOutput.() -> T): T |
Run block holding the frame lock, so the sender never transmits a half-written frame. Every Patch.render into an output goes through here. |
fun start() |
Start the sender thread (the constructor does this unless autoStart = false). |
fun flush() |
Send every universe now, regardless of change or rate; for tests and one-shot scripts. |
fun tick(nowNanos: Long, force: Boolean = false): Int |
One sender pass at time nowNanos; returns how many data packets it sent. Public so tests can drive the send-on-change / keep-alive policy with a synthetic clock and no thread. |
override fun close() |
Stop the sender and release the socket. An sACN output first sends three stream-terminated packets per universe. Idempotent. |
override fun toString() |
The protocol, where packets go, the universes this output has been asked for, packetsSent, and closed once it is. Enough to tell a live sender from one that never found a destination. |
DmxVisual.kt
Drawings of a DMX rig and its universes as FLine geometry, for a box's output or a PNG.
Two views of the same light. Patch.toFLines and Box.showPatch draw each fixture at its
position in the colour it is set to; Patch.receivedToFLines and Box.showReceived draw the
same layout in the colours decoded from what a DmxInput received, so the sent look and the
arrived one can sit side by side. universeToFLines and Box.showUniverse show a universe in
wire order instead, as a grid of slots or of fixtures read through a FixtureProfile.
renderLinesPng rasterises any of these without a browser.
src/jvmMain/kotlin/afield/io/dmx/DmxVisual.kt — read the source
Used in: A RectSL scene on a light rig, Driving Lights: Art-Net and sACN
Top-level functions
| Declaration | Summary |
|---|---|
fun Patch.toFLines(radius: Double? = null, background: Boolean = true, outline: Boolean = fixtures.size <= 64): List<FLine> |
The patch as drawable geometry: a dark ground, then one filled disc per fixture in its current colour (dimmer applied), ringed in grey so an unlit fixture is still visible. Positions are the fixtures' own; y grows downward in box output, as it does for every lines(...) figure. |
fun Patch.receivedToFLines(universeData: (Int) -> ByteArray, radius: Double? = null, background: Boolean = true, outline: Boolean = fixtures.size <= 64): List<FLine> |
The patch drawn in the colours its fixtures show in the universes universeData returns (a function from universe number to its 512 slots — input::universe for a DmxInput), decoded by Fixture.decode. It is toFLines with the wire as the source of colour instead of Fixture.color: the rig in the room, lit by what arrived, so a received frame can be set beside the look that was sent. Each universe is fetched once. |
fun universeToFLines(data: ByteArray, cols: Int = 32, cell: Double = 12.0, rgb: Boolean = false, length: Int = 512, profile: FixtureProfile? = if (rgb) FixtureProfile.RGB else null): List<FLine> |
A received (or sent) universe as geometry, in a cols-wide grid of cell-sized squares. |
fun Box.showPatch(patch: Patch, channel: String = "lights", w: Double = 640.0, h: Double = 240.0) |
Show the patch in its current colours in this box's channel output. |
fun Box.showReceived(patch: Patch, input: DmxInput, channel: String = "received", w: Double = 640.0, h: Double = 240.0) |
Show patch lit by what input has received — each fixture decoded from its own universe and address (receivedToFLines) — in this box's channel output. Beside showPatch it is the sent look and the received one in the same layout; showUniverse is the same data in wire order. |
fun Box.showUniverse(input: DmxInput, universe: Int, channel: String = "universe $universe", profile: FixtureProfile? = FixtureProfile.RGB, cols: Int = 32, w: Double = 640.0, h: Double = 240.0) |
Show one universe of input in this box's channel output: as profile fixtures (RGB pixels by default), or as 512 gray slots with profile = null. See universeToFLines. |
fun renderLinesPng(lines: List<FLine>, file: File, w: Int = 640, h: Int = 240): File |
Rasterise lines into a w × h PNG at file, fitted with a 10 px margin — for looking at a visualiser without a browser (a headless Field, a test, a docs figure). Returns the file. |
Patch.kt
A rig as data: what each fixture is, where it sits in DMX space, and where it sits in the room.
A Patch holds Fixtures, each a FixtureProfile (which Slot sits at every offset from the
start address) at some universe and address, with a position. Addresses are given explicitly or
allocated in runs by strip, stripAlong and grid, which roll over to the next universe
rather than let a fixture straddle slot 512.
Colour goes in through the mappers (map from a position and a time, mapAlong from distance
along the fixture list, mapImage sampled from an Img) or straight onto a fixture, and goes
out as slot bytes through render into a DmxOutput. writeInto is where a colour becomes
slots: white and amber are taken out of red, green and blue for the profiles that carry them,
gamma is applied, and a 16-bit slot gets its coarse and fine bytes. decode inverts that as far
as the slots allow, so a look that came back off the wire can be compared with the one sent.
Positions are the room's, in whatever unit the rig is described in; the mappers and the
toFLines drawing work in that unit and nothing here fixes it to metres. colorTemperature
turns a black-body temperature into a colour to map onto. The user-facing page is
Driving Lights: Art-Net and sACN.
src/jvmMain/kotlin/afield/io/dmx/Patch.kt — read the source
Used in: Driving Lights: Art-Net and sACN, A RectSL scene on a light rig
Top-level functions
| Declaration | Summary |
|---|---|
fun colorTemperature(kelvin: Double): Vec3 |
The colour of a black body at kelvin, as sRGB-ish 0..1 components with the largest at 1 — Tanner Helland's curve fit to Mitchell Charity's blackbody table, good from about 1000 K to 40000 K. 2700 K is a warm incandescent, 3200 K tungsten stage light, 5600 K daylight, 6500 K roughly white. |
Slot
What one DMX slot of a fixture carries. _FINE slots are the low byte of the slot before them.
Constants: RED, GREEN, BLUE, WHITE, AMBER, DIMMER, RED_FINE, GREEN_FINE, BLUE_FINE, DIMMER_FINE, ZERO, FULL.
FixtureProfile
class FixtureProfile(val name: String, val slots: List<Slot>, val gamma: Double = 1.0)
The channel layout of a kind of fixture: which Slot sits at each offset from its start address.
| Declaration | Summary |
|---|---|
val name: String |
What to call this layout. It appears in toString and in a Fixture's; nothing matches on it. |
val slots: List<Slot> |
Which Slot sits at each offset from a fixture's start address, in order; 1..512 of them. |
val gamma: Double |
Exponent applied to every 0..1 level before quantisation; 1.0 is linear. LED drivers are linear in PWM, so 2.2 makes a ramp look even to the eye. Fixture decode undoes it, so a look read back off the wire is comparable with the one sent whatever the gamma. |
val footprint: Int |
Number of DMX slots the fixture occupies. |
val hasColour: Boolean |
True if any slot carries colour (so Slot.DIMMER is a master, not the whole light). |
fun withGamma(gamma: Double) |
The same layout with a different gamma. |
override fun toString() |
The profile name and how many slots it occupies. |
Fixture
One patched fixture: a profile at 1-based address in universe, placed at position in whatever 2D units the rig is laid out in (the mappers and the visualiser use the same units).
| Declaration | Summary |
|---|---|
val name: String |
What to call this fixture. The run builders take one name for a whole run and index each member off it: strip and stripAlong count from zero as pixel 0, and grid gives column and row as pixel 0,0. |
val profile: FixtureProfile |
The channel layout: which Slot sits at each offset from address. |
val universe: Int |
Which universe the fixture is in. A Patch renders each one into the matching DmxUniverse. |
val address: Int |
1-based start address, 1..512, as a console and a fixture manual give it. The constructor refuses an address whose FixtureProfile.footprint would run past slot 512, which is why the run builders roll a long strip over to the next universe rather than straddling the boundary. |
var position: Vec2 |
Where the fixture sits in whatever 2D units the rig is laid out in; the mappers and the drawing use the same ones. Writable, so a patched rig can still be moved. |
var color: Vec3 |
Requested colour, components 0..1. |
var dimmer: Double |
Master intensity, 0..1. |
val lastAddress: Int |
Last 1-based address this fixture occupies. |
fun rgb(r: Number, g: Number, b: Number) |
Set color from components. |
fun writeInto(data: ByteArray) |
Write this fixture's slots into the 0-based universe array data. |
fun decode(data: ByteArray): Vec3 |
The look this fixture shows, read back from its slots in data (a universe's 512 slots, 0-based, as DmxInput.universe returns them): the inverse of writeInto as far as the slots allow. Fine bytes are used, FixtureProfile.gamma is undone, white is added back to all three primaries and amber to red and green (1 : 0.5), and a master dimmer is multiplied in. |
override fun toString() |
Name, profile, and the slots occupied as universe/first..last. |
Patch
class Patch(val startUniverse: Int = 1) : ToHTML
A lighting patch: fixtures, where they sit in DMX space, and where they sit in the room.
| Declaration | Summary |
|---|---|
val fixtures |
Every fixture, in the order it was added. |
val time: Double |
Seconds since this patch was made; the default t for map and mapAlong. |
val universes: List<Int> |
Universes any fixture touches, ascending. |
val cursor: Pair<Int, Int> |
Where the next allocated fixture will go, as universe to address. |
fun moveCursor(universe: Int, address: Int = 1) |
Move the allocation cursor, e.g. to start the next run on a fresh universe. |
fun add(profile: FixtureProfile, universe: Int, address: Int, position: Vec2, name: String = "${profile.name} ${fixtures.size + 1}"): Fixture |
Patch one fixture at an explicit address. Does not move the cursor. |
fun next(profile: FixtureProfile, position: Vec2, name: String = "${profile.name} ${fixtures.size + 1}"): Fixture |
Patch one fixture at the cursor, rolling to the next universe if it would not fit. |
fun strip(count: Int, from: Vec2, to: Vec2, profile: FixtureProfile = FixtureProfile.RGB, name: String = "pixel"): List<Fixture> |
A straight strip of count fixtures from from to to (inclusive), allocated from the cursor. |
fun stripAlong(line: FLine, count: Int, profile: FixtureProfile = FixtureProfile.RGB, name: String = "pixel"): List<Fixture> |
A strip of count fixtures laid evenly by arc length along line — an LED tape stuck to a drawn curve. Positions come from FLine.sampleByNumber, so the ends of the line are the ends of the strip. |
fun grid(cols: Int, rows: Int, spacing: Double = 10.0, origin: Vec2 = vec(0.0, 0.0), profile: FixtureProfile = FixtureProfile.RGB, serpentine: Boolean = false, name: String = "pixel"): List<Fixture> |
A cols × rows matrix with spacing between fixtures, top-left at origin, allocated in rows (or in a serpentine, as most LED matrices are wired, when serpentine is true). |
fun map(t: Double = time, only: List<Fixture> = fixtures, fn: (x: Double, y: Double, t: Double) -> Vec3): Patch |
Colour every fixture (or only these) from its position and time: fn(x, y, t) returns rgb 0..1. |
fun mapAlong(t: Double = time, only: List<Fixture> = fixtures, fn: (u: Double, t: Double) -> Vec3): Patch |
Colour only (default every fixture) by position along the list: fn(u, t) with u running 0..1 from the first fixture to the last — a gradient or a chase down a strip. |
fun mapImage(img: Img, only: List<Fixture> = fixtures, area: DoubleArray? = null): Patch |
Colour fixtures by sampling img bilinearly. The rig's bounding box (or area, as x, y, w, h in rig units) is stretched over the whole image, so the fixtures act as a low-resolution screen. Gray images drive r = g = b. |
fun bounds(only: List<Fixture> = fixtures): List<Double> |
[x, y, w, h] of the fixtures' positions. |
fun renderInto(universeData: (Int) -> ByteArray) |
Write every fixture into universeData — a function from universe number to its 512-byte array — without any networking. render into an output is this plus the frame lock. |
fun render(out: DmxOutput) |
Write every fixture into out's universes, atomically with respect to its sender thread. |
fun animate(out: DmxOutput, fn: Patch.(t: Double) -> Unit): Patch |
Animate this patch on out's sender clock: before every tick, fn runs with seconds since the output started (typically calling map / mapAlong / mapImage), then the patch is rendered into out. Replaces any previous animation on that output, so re-running a box is safe; stops when the output closes (i.e. when the owning box is stopped). |
fun blackout(): Patch |
Set every fixture to black. |
override fun toHTML(): String |
Evaluating a patch in a box draws it: the fixtures in their current colours (toFLines) above the toString summary. |
override fun toString(): String |
How many fixtures there are, a count per profile name, and the universes they occupy. This is also the caption under the drawing when a patch is evaluated in a box. |
Sacn.kt
Streaming ACN (ANSI E1.31-2016, "sACN") packet encoding and decoding — the wire format only.
sACN carries DMX512 universes over UDP port 5568 in three nested ACN PDUs (root layer, framing
layer, DMP layer), each opening with a 16-bit "flags and length" word whose top nibble is 0x7
and whose low 12 bits count the bytes from that word to the end of the packet. All multi-byte
fields are big-endian. This file implements:
- Data packets (root vector
0x04, framing vector0x02): 126-byte header plus a START code and up to 512 slots — 638 bytes at most. - Synchronization packets (root
0x08, framing0x01, 49 bytes): E1.31 §6.3 universe sync. A data packet whoseSynchronization Addressis non-zero is held by receivers until a sync packet for that address arrives. - Universe discovery packets (root
0x08, framing0x02, §8): the list of universes a source is transmitting, sent every 10 s (DISCOVERY_INTERVAL_MS) to universe 64214.
Universes are 1..63999; multicast traffic for universe u goes to 239.255.(u >> 8).(u & 0xff)
(multicastAddress). Every source identifies itself by a 16-byte CID (a UUID), which is also how
a receiver tells two sources on the same universe apart for priority and merging.
SacnSequence is the §6.7.2 sequence-number rule a receiver applies per source per universe.
DmxOutput and DmxInput are built on these codecs; a box reaches them through sacnOut and
sacnIn. The user-facing page is
Driving Lights: Art-Net and sACN.
src/jvmMain/kotlin/afield/io/dmx/Sacn.kt — read the source
Sacn
The sACN codecs: encoders and decoders for data, synchronization and discovery packets, and the E1.31 constants (ports, vectors, option bits, timeouts, default priority) they use.
| Declaration | Summary |
|---|---|
const val PORT |
UDP port for all E1.31 traffic: 5568 (ACN_SDT_MULTICAST_PORT). |
const val VECTOR_ROOT_E131_DATA |
Root-layer vector of a data packet, at byte 18: 0x04. |
const val VECTOR_ROOT_E131_EXTENDED |
Root-layer vector of a synchronization or a discovery packet, at byte 18: 0x08. E1.31 calls both of those extended packets, and the framing vector below says which one it is. |
const val VECTOR_E131_DATA_PACKET |
Framing-layer vector of a data packet, at byte 40: 0x02. |
const val VECTOR_E131_EXTENDED_SYNCHRONIZATION |
Framing-layer vector of a synchronization packet, at byte 40: 0x01. |
const val VECTOR_E131_EXTENDED_DISCOVERY |
Framing-layer vector of a universe discovery packet, at byte 40: 0x02. |
const val VECTOR_DMP_SET_PROPERTY |
DMP-layer vector of a data packet, at byte 117: 0x02, Set Property. It is the only DMP vector E1.31 defines for streaming DMX, and a data packet that does not carry it is dropped. |
const val VECTOR_UNIVERSE_DISCOVERY_UNIVERSE_LIST |
Vector of a discovery packet's innermost PDU, at byte 114: 0x01, a list of universes. |
const val OPTION_PREVIEW |
Options bit 7: preview data — for visualisers, not for driving lights. |
const val OPTION_STREAM_TERMINATED |
Options bit 6: stream terminated — the source is going away; drop it now. |
const val OPTION_FORCE_SYNC |
Options bit 5: force synchronization. |
const val DATA_HEADER |
Bytes before the first slot (the START code is byte 125). |
const val DATA_MAX_PACKET |
Largest data packet: 126 header bytes + 512 slots. |
const val SYNC_LENGTH |
Synchronization packets are fixed length. |
const val DISCOVERY_UNIVERSE |
Universe used for discovery packets (E1.31 §8, E131_DISCOVERY_UNIVERSE). |
const val DISCOVERY_INTERVAL_MS |
E131_UNIVERSE_DISCOVERY_INTERVAL (E1.31 Appendix A): 10 s. |
const val NETWORK_DATA_LOSS_TIMEOUT_MS |
E131_NETWORK_DATA_LOSS_TIMEOUT (E1.31 Appendix A): a source silent this long is gone. |
const val DEFAULT_PRIORITY |
Default priority for a source that does not say otherwise (E1.31 §6.2.3: 0..200, default 100). |
fun multicastAddress(universe: Int): InetAddress |
The multicast group for universe: 239.255.hi.lo. |
fun cidBytes(cid: UUID): ByteArray |
A CID as the 16 bytes E1.31 puts on the wire (UUID, most significant byte first). |
fun encodeData(buf: ByteArray, cid: ByteArray, sourceName: String, universe: Int, data: ByteArray, length: Int = 512, sequence: Int = 0, priority: Int = DEFAULT_PRIORITY, options: Int = 0, syncAddress: Int = 0, startCode: Int = 0, dataOffset: Int = 0): Int |
Encode an E1.31 data packet into buf and return its length (126 + length). |
fun encodeSync(buf: ByteArray, cid: ByteArray, syncAddress: Int, sequence: Int): Int |
Encode an E1.31 synchronization packet (49 bytes) for syncAddress and return its length. |
fun encodeDiscovery(buf: ByteArray, cid: ByteArray, sourceName: String, universes: IntArray, page: Int = 0, lastPage: Int = 0): Int |
Encode one page of a universe discovery packet and return its length (120 + 2n). universes must be sorted ascending and at most 512 long (one page). |
fun kind(buf: ByteArray, length: Int = buf.size): Kind? |
What kind of E1.31 packet buf holds, or null if it is not one we understand. |
enum class Kind |
Packet kinds this decoder recognises. |
fun decodeData(buf: ByteArray, length: Int = buf.size): Data? |
Decode a data packet; Data.data holds exactly the slots after the START code. |
fun decodeSync(buf: ByteArray, length: Int = buf.size): Sync? |
Decode a synchronization packet. |
fun decodeDiscovery(buf: ByteArray, length: Int = buf.size): Discovery? |
Decode one page of a universe discovery packet. |
class Data(val cid: UUID, val sourceName: String, val priority: Int, val syncAddress: Int, val sequence: Int, val options: Int, val universe: Int, val startCode: Int, val data: ByteArray) |
A decoded data packet. |
data class Sync(val cid: UUID, val sequence: Int, val syncAddress: Int) |
A decoded synchronization packet. |
class Discovery(val cid: UUID, val sourceName: String, val page: Int, val lastPage: Int, val universes: IntArray) |
A decoded discovery page. |
SacnSequence
The E1.31 §6.7.2 sequence-number rule, per source per universe.
| Declaration | Summary |
|---|---|
fun accept(sequence: Int): Boolean |
Returns true to accept sequence (and remember it), false to discard the packet. |
fun reset() |
Forget the last sequence number, e.g. after the source timed out. |
SceneOnARig.kt
A RectSL scene driven onto a light rig over sACN, and measured end to end.
This is the code behind A RectSL scene on a light rig: that page,
the demo document and SceneOnARigTest all run it rather than describing it, so the rig is
patched one way and measured one way wherever it appears.
The route crosses four parts of Field. A RectSL scene is drawn on the CPU into an Img
(RectSLImager); Patch mapImage colours a 16 × 8 grid of RGB pixels plus four RGBWA washes
from it; a DmxOutput sends those universes as sACN to 127.0.0.1; a DmxInput receives them,
and Fixture decode turns what arrived back into colours in the rig's layout, to be compared
with the pixels the rig was given.
Three things are measured, each by a function here: roundTrip (what the wire does to each
fixture's colour), aliasing (what sampling a 256-pixel frame at 16 points does to a thin moving
line), and timing (what a frame costs, and how often the receiver sees one while the scene
animates on the sender's 44 Hz clock).
src/jvmMain/kotlin/afield/io/dmx/SceneOnARig.kt — read the source
SceneOnARig
The rig, the two RectSL scenes it is driven with, and the three measurements. Rig patches the fixtures and colours them from a frame; SCENE and LINE are the sources roundTrip, aliasing and timing run.
| Declaration | Summary |
|---|---|
val SCENE: String |
The scene: an orange square that crosses the wall and turns, a thin white line, a blue band that fades in and out. |
val LINE: String |
The line alone on black: a quarter of a fixture's width, crossing the wall in four seconds. |
const val COLS |
Grid columns: one fixture per wall unit across, matching the scene's ->wall 16 8. |
const val ROWS |
Grid rows: one fixture per wall unit down. |
const val FRAME_W |
Width the scene is drawn at before it meets the rig: 16 pixels per fixture across. |
const val FRAME_H |
Height the scene is drawn at: 16 pixels per fixture down, so pixels are square. |
class Rig(startUniverse: Int = 1) |
The rig: a COLS × ROWS grid of RGB pixels 10 units apart in universe 1 (384 of its 512 slots), and four RGBWA washes along the bottom in universe 2. Positions are in rig units, y down, as every lines(...) drawing is. |
class RoundTrip(val frames: Int, val fixtures: Int, val slotsCompared: Int, val levelErrors: Int, val worstGrid: Double, val worstWash: Double, val packetsSent: Long, val packetsReceived: Long, val medianLatencyMicros: Double, val lastSent: Img, val lastReceived: Img) |
What the network did to the rig, over times. levelErrors counts slot bytes that differ between what was rendered and what DmxInput merged (it should be zero: sACN is lossless); worstGrid and worstWash are the largest per-channel difference, in 0..1, between the colour a fixture was asked for and the colour Fixture.decode recovers from the received slots. |
fun roundTrip(port: Int = 0, times: List<Double> = (0..8).map { it * 0.5 }, scene: RectSLImager = RectSLImager(SCENE)): RoundTrip |
Send the scene at each of times as sACN to 127.0.0.1:port and receive it back on the same port with Field's DmxInput; compare. Blocks until each frame has arrived (or 2 s). With port = 0 (the default) the receiver binds a free port and the sender is pointed at it. |
fun decodeErrors(count: Int = 2000, seed: Long = 7): Map<FixtureProfile, Double> |
The worst difference, in 8-bit levels, between the colour a fixture of each preset profile was asked for and what Fixture.decode reads back from the slots it wrote — over count random colours (and random master dimmers for FixtureProfile.DRGB), seeded, with no network in between. It is the floor under any round trip: what quantising to the profile's slots costs. |
class SamplingTrace(val name: String, val total: DoubleArray, val peak: DoubleArray) |
One sampling route through the LINE sweep: per frame, the total light on the grid (the sum over fixtures of their mean channel, so a fully lit fixture is 1) and the brightest fixture. |
fun aliasing(frames: Int = 121, line: RectSLImager = RectSLImager(LINE)): Pair<SamplingTrace, SamplingTrace> |
The aliasing measurement. The LINE scene, drawn in linear light at FRAME_W × FRAME_H, is put on the grid two ways over frames frames of the middle 3 seconds (the line wholly on the wall): bilinear point samples of the full frame (Patch.mapImage as it is), and the frame area-averaged to one pixel per fixture first. The line covers a quarter of one column of fixtures, so the light it should put on the rig is 0.25 × ROWS = 2 fixtures' worth, wherever it is. |
const val LINE_TRUTH: Double |
The light the line should put on the grid, in fully-lit fixtures. |
class Timing(val seconds: Double, val ticks: Int, val frameMillis: DoubleArray, val arrivalMillis: DoubleArray, val lastError: Throwable?) |
What one timing run measured: what each look cost to make, and how the frames arrived. |
fun timing(port: Int = 0, seconds: Double = 3.0, scene: RectSLImager = RectSLImager(SCENE)): Timing |
Animate the scene on an output's own sender clock for seconds, and measure what each look costs and what arrives. |