diff --git a/MusicBeam/ArtNet.pde b/MusicBeam/ArtNet.pde new file mode 100644 index 0000000..a3ef426 --- /dev/null +++ b/MusicBeam/ArtNet.pde @@ -0,0 +1,100 @@ +import java.net.DatagramSocket; +import java.net.DatagramPacket; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; + +// Art-Net (DMX over Ethernet) effect selection. +// Reads a single DMX channel; its value selects the active effect by index +// (0 = Blackout / no effect, 1..N = the other effects). Opt-in via the +// "Art-Net (DMX)" toggle. Uses only the JDK, no extra libraries. + +static final byte[] ART_NET_ID = "Art-Net\0".getBytes(); +static final int ART_NET_OP_DMX = 0x5000; // ArtDMX opcode +static final int ART_NET_DMX_DATA = 18; // offset of the DMX data in an ArtDMX packet +static final int ART_NET_PORT = 6454; // Art-Net standard UDP port +static final int ART_NET_UNIVERSE = 0; // 15-bit universe to listen on +static final int ART_NET_CHANNEL = 1; // 1-indexed DMX channel = effect index + +ArtNet artNet; + +class ArtNet implements Runnable +{ + final AtomicInteger pending = new AtomicInteger(Integer.MIN_VALUE); + volatile boolean running = false; + int lastValue = -1; // edge-trigger; only touched by the receive thread + DatagramSocket socket; + + // Called from controlEvent() on the animation thread. Idempotent. + void setEnabled(boolean on) + { + if (on && !running) { + try { + socket = new DatagramSocket(ART_NET_PORT); + running = true; + lastValue = -1; // re-arm so the current console value is applied + new Thread(this).start(); + println("Art-Net listening on UDP " + ART_NET_PORT + ", universe " + ART_NET_UNIVERSE + ", channel " + ART_NET_CHANNEL); + } + catch (Exception e) { + println("Art-Net failed to start: " + e.getMessage()); + running = false; + socket = null; + } + } else if (!on && running) { + running = false; + if (socket != null) + socket.close(); + socket = null; + } + } + + // Receive loop; runs on its own thread and never touches ControlP5. + void run() + { + byte[] buf = new byte[530]; // 18 byte header + up to 512 DMX channels + DatagramPacket pkt = new DatagramPacket(buf, buf.length); + while (running) { + try { + pkt.setLength(buf.length); // receive() shrinks length; reset so large packets aren't truncated + socket.receive(pkt); + parse(pkt.getData(), pkt.getLength()); + } + catch (Exception e) { + if (running) + println("Art-Net receive error: " + e.getMessage()); + } + } + } + + // Read the selected channel of an ArtDMX packet and hand its value to the + // animation thread. Anything else is ignored. + void parse(byte[] b, int len) + { + int i = ART_NET_DMX_DATA + ART_NET_CHANNEL - 1; + if (len > i + && Arrays.equals(b, 0, ART_NET_ID.length, ART_NET_ID, 0, ART_NET_ID.length) + && le16(b, 8) == ART_NET_OP_DMX + && (le16(b, 14) & 0x7fff) == ART_NET_UNIVERSE) + { + int value = b[i] & 0xff; + if (value != lastValue) { // edge-trigger so the GUI stays usable between changes + lastValue = value; + pending.set(value); + } + } + } + + // 16 bit little-endian read. + int le16(byte[] b, int i) + { + return (b[i] & 0xff) | ((b[i+1] & 0xff) << 8); + } + + // Called once per frame from draw(); the only place ControlP5 is touched. + void update() + { + int v = pending.getAndSet(Integer.MIN_VALUE); + if (v >= 0 && v < effectArray.length) + effectArray[v].activate(); + } +} diff --git a/MusicBeam/MusicBeam.pde b/MusicBeam/MusicBeam.pde index 562dcf2..04e1b48 100644 --- a/MusicBeam/MusicBeam.pde +++ b/MusicBeam/MusicBeam.pde @@ -34,7 +34,7 @@ Effect[] effectArray; DropdownList displays; -Toggle projectorToggle, randomToggle, blackoutOnSilenceToggle; +Toggle projectorToggle, randomToggle, blackoutOnSilenceToggle, artNetToggle; Slider randomTimeSlider, beatDelaySlider, minLevelSlider; Button nextButton; RadioButton activeEffect, activeSetting; @@ -93,6 +93,9 @@ void draw() { effectArray[int(activeSetting.getValue())].showControls(); } + if (artNet != null) + artNet.update(); + if (randomToggle!=null) if (randomToggle.getState()&&randomTimer>=randomTimeSlider.getValue()*60) { @@ -115,6 +118,8 @@ void controlEvent(ControlEvent event) { if (event.getName()=="next") nextRandom(); + else if (event.getName()=="artNet" && artNet!=null) + artNet.setEnabled(artNetToggle.getState()); } void drawBeatBoard() @@ -222,6 +227,7 @@ void initControls() PApplet.runSketch(args, stage); stage.noLoop(); initEffects(); + artNet = new ArtNet(); initSettings(); stage.loop(); } @@ -237,9 +243,12 @@ void initRandomControls() { nextButton = cp5.addButton("next").setSize(350, 45).setPosition(415, 60); nextButton.getCaptionLabel().set("Next Effect").align(ControlP5.CENTER, ControlP5.CENTER); - blackoutOnSilenceToggle = cp5.addToggle("blackoutOnSilence").setSize(350, 45).setPosition(415, 110); + blackoutOnSilenceToggle = cp5.addToggle("blackoutOnSilence").setSize(172, 45).setPosition(415, 110); blackoutOnSilenceToggle.getCaptionLabel().set("Blackout on Silence").align(ControlP5.CENTER, ControlP5.CENTER); + artNetToggle = cp5.addToggle("artNet").setSize(172, 45).setPosition(593, 110); + artNetToggle.getCaptionLabel().set("Art-Net (DMX)").align(ControlP5.CENTER, ControlP5.CENTER); + activeEffect = cp5.addRadioButton("activeEffects").setPosition(415, 170).setSize(250, 45).setItemsPerRow(1).setSpacingRow(5).setNoneSelectedAllowed(true); activeSetting = cp5.addRadioButton("activeSettings").setPosition(720, 170).setSize(45, 45).setItemsPerRow(1).setSpacingRow(5); } diff --git a/README.md b/README.md index 5d28574..5e52395 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,17 @@ MusicBeam is written in [Processing][Processing] a JAVA based language that is e The latest releases can be found at: [musicbeam.org](http://www.musicbeam.org) +## Art-Net (DMX) Control + +The active effect can be selected over the network with Art-Net messages. The button **Art-Net (DMX)** starts a background thread that listens on UDP port 6454 for DMX frames and monitors channel 1 of universe 0. The channel value gets mapped to the available Effects. Channel value 0 maps to the Blackout effect. + +It can be tried out using [`examples/artnet_test.py`](examples/artnet_test.py): + +```console +python3 examples/artnet_test.py 3 # select effect 3 +``` + + ## Contributing MusicBeam is writing in Processing a Java sublanguage. It is extremely easy to write even without prior coding experience. diff --git a/examples/artnet_test.py b/examples/artnet_test.py new file mode 100755 index 0000000..2508ff8 --- /dev/null +++ b/examples/artnet_test.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Test MusicBeam's Art-Net effect selection by sending ArtDMX frames. + +Enable the "Art-Net (DMX)" toggle in MusicBeam first, then: + + python3 artnet_test.py 3 select effect 3 + python3 artnet_test.py 0 blackout / no effect + python3 artnet_test.py demo cycle through all effects, then blackout + python3 artnet_test.py 3 192.168.1.20 send to another machine +""" +import socket +import sys +import time + +EFFECT_COUNT = 11 # effect indices 0..10, 0 = Blackout + + +def send(value, host): + packet = bytearray(b"Art-Net\x00") + packet += (0x5000).to_bytes(2, "little") # OpCode: ArtDMX + packet += (14).to_bytes(2, "big") # protocol version + packet += bytes([0, 0]) # sequence, physical + packet += bytes([0, 0]) # universe 0 + packet += (1).to_bytes(2, "big") # one DMX channel + packet += bytes([value]) # channel 1 = effect index + socket.socket(socket.AF_INET, socket.SOCK_DGRAM).sendto(packet, (host, 6454)) + print(f"sent effect index {value} to {host}") + + +if len(sys.argv) < 2: + sys.exit(__doc__) +host = sys.argv[2] if len(sys.argv) > 2 else "127.0.0.1" +if sys.argv[1] == "demo": + for value in list(range(EFFECT_COUNT)) + [0]: + send(value, host) + time.sleep(2) +else: + send(int(sys.argv[1]), host)