Skip to content

Commit 7008efb

Browse files
committed
Dual MIDI port support, improved controller refresh
* Dual ports: added secondary MIDI input for all hardware controls, enabling side-by-side use with NI Maschine software * Fixed strip values missing on mirror port during return-from-host * Fixed meters showing fader values instead of levels after return-from-host in DUAL mode * Fixed sysex handler to work from non-controller threads * Dev build numbering in Maven output and extension log * Integration tests: auto-activate audio engine in health check
1 parent 6f56a9f commit 7008efb

9 files changed

Lines changed: 221 additions & 112 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ This is intended as a replacement for Bitwig's bundled Maschine JAM extension. R
2525
1. Follow the installation instructions for each of the above requirements.
2626
2. Run `mvn install`.
2727

28+
### Dual MIDI ports (mirror port)
29+
30+
The extension can be built with a second MIDI in/out port pair that mirrors all
31+
output sent to the hardware. This is useful for external tools (emulators,
32+
test harnesses, visualizers) that need to observe the controller's LED and
33+
strip state in real time. The secondary input port also forwards incoming
34+
MIDI to the extension, so an external tool can inject button presses and
35+
fader moves.
36+
37+
To enable, set the `dualPorts` Maven property to `true`:
38+
39+
```bash
40+
mvn install -DdualPorts=true
41+
```
42+
43+
The included `bin/build-info.sh` dev-build script enables dual ports
44+
automatically on non-master branches.
45+
2846
### Debugging
2947

3048
1. Set an environment variable `BITWIG_DEBUG_PORT` to an unused port number.

bin/build-info.sh

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env bash
2+
# Generate target/build-info.properties for dev builds.
3+
# Run by Maven via the dev-build-info profile (activated when this file exists).
4+
# Not committed — bin/ is untracked.
5+
6+
set -euo pipefail
7+
8+
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
9+
if [ "$BRANCH" = "master" ]; then
10+
DUAL_PORTS=false
11+
else
12+
DUAL_PORTS=true
13+
fi
14+
15+
DESC=$(git describe --tags --match "devel-v*" 2>/dev/null || true)
16+
if [ -z "$DESC" ]; then
17+
mkdir -p target
18+
printf 'buildSuffix=\ndualPorts=%s\n' "$DUAL_PORTS" > target/build-info.properties
19+
echo "[build-info] no devel tag found, buildSuffix empty"
20+
exit 0
21+
fi
22+
23+
COUNTER_FILE="$(git rev-parse --git-common-dir)/monsterjam-build-number"
24+
[ ! -f "$COUNTER_FILE" ] && echo "0" > "$COUNTER_FILE"
25+
26+
N=$(( $(cat "$COUNTER_FILE") + 1 ))
27+
echo "$N" > "$COUNTER_FILE"
28+
29+
mkdir -p target
30+
printf 'buildSuffix=-dev.%s\ndualPorts=%s\n' "$N" "$DUAL_PORTS" > target/build-info.properties
31+
echo "[build-info] build #$N (dualPorts=$DUAL_PORTS)"

pom.xml

Lines changed: 51 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<artifactId>monsterjam</artifactId>
88
<packaging>jar</packaging>
99
<name>MonsterJam</name>
10-
<version>9.2</version>
10+
<version>9.3</version>
1111

1212
<properties>
1313
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -18,6 +18,7 @@
1818
<scala.xml.version>2.3.0</scala.xml.version>
1919
<bitwig.api.version>21</bitwig.api.version>
2020
<buildSuffix></buildSuffix>
21+
<dualPorts>false</dualPorts>
2122
</properties>
2223

2324
<repositories>
@@ -93,6 +94,55 @@
9394
<bitwig.extension.directory>${env.HOME}/Bitwig Studio/Extensions</bitwig.extension.directory>
9495
</properties>
9596
</profile>
97+
<profile>
98+
<id>dev-build-info</id>
99+
<activation>
100+
<file>
101+
<exists>bin/build-info.sh</exists>
102+
</file>
103+
</activation>
104+
<build>
105+
<plugins>
106+
<plugin>
107+
<groupId>org.codehaus.mojo</groupId>
108+
<artifactId>exec-maven-plugin</artifactId>
109+
<version>3.1.0</version>
110+
<executions>
111+
<execution>
112+
<id>generate-build-info</id>
113+
<phase>initialize</phase>
114+
<goals>
115+
<goal>exec</goal>
116+
</goals>
117+
<configuration>
118+
<executable>bin/build-info.sh</executable>
119+
</configuration>
120+
</execution>
121+
</executions>
122+
</plugin>
123+
<plugin>
124+
<groupId>org.codehaus.mojo</groupId>
125+
<artifactId>properties-maven-plugin</artifactId>
126+
<version>1.2.1</version>
127+
<executions>
128+
<execution>
129+
<id>read-build-info</id>
130+
<phase>initialize</phase>
131+
<goals>
132+
<goal>read-project-properties</goal>
133+
</goals>
134+
<configuration>
135+
<files>
136+
<file>${project.build.directory}/build-info.properties</file>
137+
</files>
138+
<override>true</override>
139+
</configuration>
140+
</execution>
141+
</executions>
142+
</plugin>
143+
</plugins>
144+
</build>
145+
</profile>
96146
</profiles>
97147

98148
<build>
@@ -116,61 +166,6 @@
116166
</resources>
117167
<finalName>${project.artifactId}-${project.version}</finalName>
118168
<plugins>
119-
<plugin>
120-
<groupId>org.codehaus.mojo</groupId>
121-
<artifactId>exec-maven-plugin</artifactId>
122-
<version>3.1.0</version>
123-
<executions>
124-
<execution>
125-
<id>increment-build-number</id>
126-
<phase>initialize</phase>
127-
<goals>
128-
<goal>exec</goal>
129-
</goals>
130-
<configuration>
131-
<executable>bash</executable>
132-
<arguments>
133-
<argument>-c</argument>
134-
<argument><![CDATA[
135-
DESC=$(git describe --tags --match "devel-v*" 2>/dev/null || true)
136-
if [ -z "$DESC" ]; then
137-
mkdir -p target && echo "buildSuffix=" > target/build-info.properties
138-
exit 0
139-
fi
140-
141-
COUNTER_FILE="$(git rev-parse --git-common-dir)/monsterjam-build-number"
142-
[ ! -f "$COUNTER_FILE" ] && echo "0" > "$COUNTER_FILE"
143-
144-
N=$(( $(cat "$COUNTER_FILE") + 1 ))
145-
echo "$N" > "$COUNTER_FILE"
146-
147-
mkdir -p target
148-
echo "buildSuffix=-dev.${N}" > target/build-info.properties
149-
]]></argument>
150-
</arguments>
151-
</configuration>
152-
</execution>
153-
</executions>
154-
</plugin>
155-
<plugin>
156-
<groupId>org.codehaus.mojo</groupId>
157-
<artifactId>properties-maven-plugin</artifactId>
158-
<version>1.2.1</version>
159-
<executions>
160-
<execution>
161-
<id>read-build-info</id>
162-
<phase>initialize</phase>
163-
<goals>
164-
<goal>read-project-properties</goal>
165-
</goals>
166-
<configuration>
167-
<files>
168-
<file>${project.build.directory}/build-info.properties</file>
169-
</files>
170-
</configuration>
171-
</execution>
172-
</executions>
173-
</plugin>
174169
<plugin>
175170
<groupId>net.alchim31.maven</groupId>
176171
<artifactId>scala-maven-plugin</artifactId>

src/main/scala/com/github/unthingable/MonsterJamControllerDefinition.scala

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import java.util.UUID
1010
object MonsterJamExtensionDefinition:
1111
private val DRIVER_ID = UUID.fromString("b4b8b16c-5855-4943-a8c6-45cbdaf9aee1")
1212

13-
val version: String =
14-
val props = new java.util.Properties()
13+
private val props =
14+
val p = new java.util.Properties()
1515
val stream = getClass.getResourceAsStream("/monsterjam.properties")
1616
if stream != null then
17-
try props.load(stream)
17+
try p.load(stream)
1818
finally stream.close()
19-
props.getProperty("version", "unknown")
19+
p
20+
21+
val version: String = props.getProperty("version", "unknown")
22+
val dualPorts: Boolean = props.getProperty("dualPorts", "false").toBoolean
2023

2124
class MonsterJamExtensionDefinition() extends ControllerExtensionDefinition:
2225
override def getName = "MonsterJam"
@@ -33,14 +36,15 @@ class MonsterJamExtensionDefinition() extends ControllerExtensionDefinition:
3336

3437
override def getRequiredAPIVersion = 21
3538

36-
override def getNumMidiInPorts = 1
39+
override def getNumMidiInPorts = if MonsterJamExtensionDefinition.dualPorts then 2 else 1
3740

38-
override def getNumMidiOutPorts = 1
41+
override def getNumMidiOutPorts = if MonsterJamExtensionDefinition.dualPorts then 2 else 1
3942

4043
override def listAutoDetectionMidiPortNames(list: AutoDetectionMidiPortNamesList, platformType: PlatformType): Unit =
44+
val extra = if MonsterJamExtensionDefinition.dualPorts then Array("") else Array.empty[String]
4145
(1 to 4).foreach { n =>
42-
list.add(Array[String](s"Maschine Jam - $n"), Array[String](s"Maschine Jam - $n"))
43-
list.add(Array[String](s"Maschine Jam - $n Input"), Array[String](s"Maschine Jam - $n Output"))
46+
list.add(Array(s"Maschine Jam - $n") ++ extra, Array(s"Maschine Jam - $n") ++ extra)
47+
list.add(Array(s"Maschine Jam - $n Input") ++ extra, Array(s"Maschine Jam - $n Output") ++ extra)
4448
}
4549

4650
override def createInstance(host: ControllerHost) = new MonsterJamExtension(this, host)

src/main/scala/com/github/unthingable/MonsterJamExtension.scala

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.github.unthingable.framework.EventBus
77
import com.github.unthingable.framework.binding.Binder
88
import com.github.unthingable.framework.binding.Event
99
import com.github.unthingable.jam.Jam
10+
import com.github.unthingable.harness.MirroringMidiOut
1011
import com.github.unthingable.jam.surface.XmlMap
1112
import com.github.unthingable.jam.surface.XmlMap.loadMap
1213

@@ -37,6 +38,7 @@ case class MonsterJamExt(
3738
host: ControllerHost,
3839
midiIn: MidiIn,
3940
midiOut: MidiOut,
41+
debugMidiIn: Option[MidiIn],
4042
hw: HardwareSurface,
4143
cursorTrack: CursorTrack,
4244
trackBank: TrackBank,
@@ -89,10 +91,17 @@ class MonsterJamExtension(val definition: MonsterJamExtensionDefinition, val hos
8991

9092
override def init(): Unit =
9193
val host = getHost
94+
95+
val dualPorts = MonsterJamExtensionDefinition.dualPorts
96+
97+
val mirroringOut = new MirroringMidiOut(host.getMidiOutPort(0))
98+
if dualPorts then mirroringOut.setMirror(host.getMidiOutPort(1))
99+
92100
ext = MonsterJamExt(
93101
host,
94102
host.getMidiInPort(0),
95-
host.getMidiOutPort(0),
103+
mirroringOut,
104+
if dualPorts then Some(host.getMidiInPort(1)) else None,
96105
host.createHardwareSurface,
97106
host.createCursorTrack(1, 0),
98107
host.createMainTrackBank(8, 8, 8),
@@ -134,10 +143,12 @@ class MonsterJamExtension(val definition: MonsterJamExtensionDefinition, val hos
134143

135144
jam = new Jam()(ext)
136145

137-
if ext.preferences.debugOutput.get() then
146+
if dualPorts || ext.preferences.debugOutput.get() then
138147
initOsc(host)
139148

140-
host.showPopupNotification(s"MonsterJam ${MonsterJamExtensionDefinition.version} Initialized")
149+
val versionStr = s"MonsterJam ${MonsterJamExtensionDefinition.version}"
150+
Util.println(s"$versionStr Initialized")
151+
host.showPopupNotification(s"$versionStr Initialized")
141152
end init
142153

143154
private def initOsc(host: ControllerHost): Unit =

src/main/scala/com/github/unthingable/jam/surface/JamControl.scala

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,17 @@ object JamControl:
8383

8484
button
8585

86+
/** Collected reset functions for all lights — call before invalidate+flush to force resend. */
87+
val lightResetters = scala.collection.mutable.ArrayBuffer.empty[() => Unit]
88+
8689
def rgbLight(info: MidiInfo)(implicit ext: MonsterJamExt): MultiStateHardwareLight =
8790
import JamColorState.*
8891
val light: MultiStateHardwareLight = ext.hw.createMultiStateHardwareLight(info.id + "_LED")
8992
var updatedColorState: JamColorState = JamColorState.empty
9093
var lastSentColor: Int = -1
9194

95+
lightResetters += (() => lastSentColor = -1)
96+
9297
light.setColorToStateFunction(toState)
9398
light.state().onUpdateHardware { (state: JamColorState) =>
9499
updatedColorState = state
@@ -104,31 +109,30 @@ object JamControl:
104109
def sendColor(color: Int): Unit =
105110
info.event match
106111
case CC(cc) =>
107-
// ext.host.println(s"${info.id} setting CC ${info.channel} ${cc} ${color.toString}")
108112
ext.midiOut.sendMidi(ShortMidiMessage.CONTROL_CHANGE + info.channel, cc, color)
109113
case Note(note) =>
110-
// ext.host.println(s"${info.id} setting NOTE ${info.channel} ${note} ${color.toString}")
111114
ext.midiOut.sendMidi(ShortMidiMessage.NOTE_ON + info.channel, note, color)
112115
light
113116
end rgbLight
114117

115118
def onOffLight(info: MidiInfo)(implicit ext: MonsterJamExt): OnOffHardwareLight =
116119
val light: OnOffHardwareLight = ext.hw.createOnOffHardwareLight(info.id + "_LED")
117120
var lastSentOn: Int = -1
121+
122+
lightResetters += (() => lastSentOn = -1)
123+
118124
light.onUpdateHardware { () =>
119125
val onValue = if light.isOn.currentValue then 1 else 0
120126
if onValue != lastSentOn then
121127
lastSentOn = onValue
122128
info.event match
123129
case CC(cc) =>
124-
// ext.host.println(s"${info.id} setting CC ${info.channel} ${cc} ${light.isOn.currentValue()}")
125130
ext.midiOut.sendMidi(
126131
ShortMidiMessage.CONTROL_CHANGE + info.channel,
127132
cc,
128133
if light.isOn.currentValue then 127 else 0
129134
)
130135
case Note(note) =>
131-
// ext.host.println(s"${info.id} setting NOTE ${info.channel} ${note} ${light.isOn.currentValue()}")
132136
ext.midiOut.sendMidi(
133137
ShortMidiMessage.NOTE_ON + info.channel,
134138
note,
@@ -147,17 +151,26 @@ object JamControl:
147151
end JamControl
148152

149153
object JamButton:
154+
private def orAction(primary: HardwareActionMatcher, debugMidiIn: Option[MidiIn], f: MidiIn => HardwareActionMatcher)(using ext: MonsterJamExt): HardwareActionMatcher =
155+
debugMidiIn match
156+
case Some(di) => ext.host.createOrHardwareActionMatcher(primary, f(di))
157+
case None => primary
158+
150159
def infoActionMatchers(info: MidiInfo)(using ext: MonsterJamExt): (HardwareActionMatcher, HardwareActionMatcher) =
151160
info.event match
152161
case CC(cc) =>
153162
(
154-
ext.midiIn.createCCActionMatcher(info.channel, cc, 127),
155-
ext.midiIn.createCCActionMatcher(info.channel, cc, 0)
163+
orAction(ext.midiIn.createCCActionMatcher(info.channel, cc, 127), ext.debugMidiIn,
164+
_.createCCActionMatcher(info.channel, cc, 127)),
165+
orAction(ext.midiIn.createCCActionMatcher(info.channel, cc, 0), ext.debugMidiIn,
166+
_.createCCActionMatcher(info.channel, cc, 0))
156167
)
157168
case Note(note) =>
158169
(
159-
ext.midiIn.createNoteOnActionMatcher(info.channel, note),
160-
ext.midiIn.createNoteOffActionMatcher(info.channel, note)
170+
orAction(ext.midiIn.createNoteOnActionMatcher(info.channel, note), ext.debugMidiIn,
171+
_.createNoteOnActionMatcher(info.channel, note)),
172+
orAction(ext.midiIn.createNoteOffActionMatcher(info.channel, note), ext.debugMidiIn,
173+
_.createNoteOffActionMatcher(info.channel, note))
161174
)
162175

163176
/* These represent actual hardware buttons, with one HardwareButton with raw events and one light.
@@ -183,7 +196,11 @@ class JamTouchStrip(touch: MidiInfo, slide: MidiInfo, led: MidiInfo)(using ext:
183196
slider.isBeingTouched.markInterested()
184197

185198
// assume it's always CC
186-
val matcher = ext.midiIn.createAbsoluteCCValueMatcher(slide.channel, slide.event.value)
199+
val primaryMatcher = ext.midiIn.createAbsoluteCCValueMatcher(slide.channel, slide.event.value)
200+
val matcher = ext.debugMidiIn match
201+
case Some(di) => ext.host.createOrAbsoluteHardwareValueMatcher(primaryMatcher,
202+
di.createAbsoluteCCValueMatcher(slide.channel, slide.event.value))
203+
case None => primaryMatcher
187204
slider.setAdjustValueMatcher(matcher)
188205

189206
val (on, off) = JamButton.infoActionMatchers(touch)

0 commit comments

Comments
 (0)