tuio.kt
src/jvmMain/kotlin/afield/io/osc/tuio.kt · 383 lines
package and imports — 45 lines, from line 1
@file:Suppress("MemberVisibilityCanBePrivate")
@file:afield.runtime.completion.CompletionCategory("Data")
package afield.io.osc
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.m
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.i
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.audio.synth.*
import afield.data.collections.r
import afield.document.properties.clear
import afield.graphics.vulkan.buffers.add
import afield.math.linalg.x
import afield.media.image.close
import afield.runtime.execution.start
import com.illposed.osc.OSCMessage
import com.illposed.osc.OSCMessageListener
import com.illposed.osc.messageselector.OSCPatternAddressMessageSelector
import com.illposed.osc.transport.OSCPortIn
import java.io.Closeable
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
A minimal TUIO receiver. TuioReceiver opens a UDP OSC port (3333 by default) through JavaOSC
and listens on /tuio/2Dcur and /tuio/2Dobj only — no blobs, no 2.5D or 3D profiles. It keeps
the live cursor and object sets, deduces additions and removals by diffing successive ALIVE
messages, and on each FSEQ message hands your TuioListener one TuioReceiver.Frame holding
both the full state and the per-frame added / updated / removed lists.
Call TuioReceiver.start to bind and TuioReceiver.close (or stop()) to release the port;
both are idempotent, and starting a receiver closes the one previously started on its port, so a
box can build a fresh receiver on every run. Callbacks arrive on JavaOSC's receive thread, not
yours, and all internal state is guarded by one lock. Each profile's FSEQ emits a frame, so a
tracker that sends both cursors and objects produces two frames per tracker frame, each carrying
the full state of both. Numeric OSC arguments are coerced leniently, and malformed set messages
are skipped rather than reported. main is a demo that prints frame deltas.
fun interface TuioListener
fun onFrame(frame: TuioReceiver.Frame)
class TuioReceiver
Minimal TUIO/UDP receiver for: - /tuio/2Dcur (cursors) - /tuio/2Dobj (fiducials/objects)
It deduces add/remove from successive ALIVE messages, and emits changes on FSEQ.
TUIO message formats (spec): /tuio/2Dcur source application@address /tuio/2Dcur alive s_id0 ... s_idN /tuio/2Dcur set s_id x_pos y_pos x_vel y_vel m_accel /tuio/2Dcur fseq f_id
/tuio/2Dobj set s i x y a X Y A m r
class TuioReceiver(
private val localPort: Int = 3333,
private val listener: TuioListener = TuioListener {}
) : Closeable {
data class TuioCursor
data class TuioCursor(
val sessionId: Long,
val x: Float,
val y: Float,
val xVel: Float,
val yVel: Float,
val motionAccel: Float
)
data class TuioObject
data class TuioObject(
val sessionId: Long,
val classId: Long,
val x: Float,
val y: Float,
val angle: Float,
val xVel: Float,
val yVel: Float,
val aVel: Float,
val motionAccel: Float,
val rotationAccel: Float
)
data class Frame
data class Frame(
val fseq: Int,
val source: String?,
val cursors: Collection<TuioCursor>,
val objects: Collection<TuioObject>,
val cursorAdded: List<TuioCursor>,
val cursorUpdated: List<TuioCursor>,
val cursorRemoved: List<TuioCursor>,
val objectAdded: List<TuioObject>,
val objectUpdated: List<TuioObject>,
val objectRemoved: List<TuioObject>
)
private val lock = ReentrantLock()
Current state
private val cursors = LinkedHashMap<Long, TuioCursor>()
private val objects = LinkedHashMap<Long, TuioObject>()
“Alive” tracking (used to deduce add/remove)
private var aliveCursors: Set<Long> = emptySet()
private var aliveObjects: Set<Long> = emptySet()
Per-frame deltas (emitted on fseq)
private val cursorAdded = LinkedHashSet<Long>()
private val cursorUpdated = LinkedHashSet<Long>()
private val cursorRemoved = LinkedHashMap<Long, TuioCursor>()
private val objectAdded = LinkedHashSet<Long>()
private val objectUpdated = LinkedHashSet<Long>()
private val objectRemoved = LinkedHashMap<Long, TuioObject>()
private var currentSource: String? = null
private var portIn: OSCPortIn? = null
private companion object {
/** The started receiver on each port; see [start]. */
val started = java.util.concurrent.ConcurrentHashMap<Int, TuioReceiver>()
}
fun start()
Bind the port and start delivering frames. Idempotent.
Starting a receiver closes any other receiver this JVM started on the same port. Re-running a
box that builds a new TuioReceiver would otherwise leave the old one bound: JavaOSC sets
SO_REUSEADDR, so the second bind succeeds, and measured on macOS the datagrams keep going to
the first socket, whose listener belongs to the code you just replaced.
fun start() {
started.put(localPort, this)?.takeIf { it !== this }?.close()
lock.withLock {
if (portIn != null) return
// Not OSCPortInBuilder.addMessageListener: in JavaOSC 0.9 that throws a
// NullPointerException when it is the first thing called on a fresh builder, which made
// start() fail on every call. Nor connect(): a receive-only port has no remote address.
portIn = openOscPortIn(localPort).also { p ->
for (profile in listOf("/tuio/2Dcur", "/tuio/2Dobj")) {
p.dispatcher.addListener(
OSCPatternAddressMessageSelector(profile),
OSCMessageListener { msg -> handleTuioMessage(msg.message) }
)
}
p.startListening()
}
}
}
fun stop()
fun stop() {
close()
}
override fun close()
body — 185 lines, from line 182
override fun close() {
started.remove(localPort, this)
lock.withLock {
portIn?.let { p ->
try { p.stopListening() } catch (_: Throwable) {}
try { p.close() } catch (_: Throwable) {}
}
portIn = null
}
}
private fun handleTuioMessage(message: OSCMessage) {
// All TUIO commands are string in arg0: "source" / "alive" / "set" / "fseq"
val addr = message.address
val args = message.arguments ?: emptyList<Any>()
if (args.isEmpty() || args[0] !is String) return
val cmd = args[0] as String
lock.withLock {
when (addr) {
"/tuio/2Dcur" -> handle2DCur(cmd, args)
"/tuio/2Dobj" -> handle2DObj(cmd, args)
else -> Unit
}
}
}
private fun handle2DCur(cmd: String, args: List<Any>) {
when (cmd) {
"source" -> {
currentSource = args.getOrNull(1)?.toString()
}
"alive" -> {
val newAlive = args.drop(1).mapNotNull { it.toLongOrNullSafe() }.toSet()
val removed = aliveCursors - newAlive
val added = newAlive - aliveCursors
removed.forEach { id ->
cursors.remove(id)?.let { cursorRemoved[id] = it }
cursorAdded.remove(id)
cursorUpdated.remove(id)
}
added.forEach { id -> cursorAdded.add(id) }
aliveCursors = newAlive
}
"set" -> {
// set s_id x y X Y m
if (args.size < 7) return
val sId = args[1].toLongOrNullSafe() ?: return
val cursor = TuioCursor(
sessionId = sId,
x = args[2].toFloatSafe(),
y = args[3].toFloatSafe(),
xVel = args[4].toFloatSafe(),
yVel = args[5].toFloatSafe(),
motionAccel = args[6].toFloatSafe()
)
val existed = cursors.containsKey(sId)
cursors[sId] = cursor
// A session first seen in this frame is an addition only, whether ALIVE or SET named it first.
if (!existed) cursorAdded.add(sId) else if (sId !in cursorAdded) cursorUpdated.add(sId)
}
"fseq" -> {
val fseq = args.getOrNull(1)?.toIntOrNullSafe() ?: return
emitFrame(fseq)
}
}
}
private fun handle2DObj(cmd: String, args: List<Any>) {
when (cmd) {
"source" -> {
currentSource = args.getOrNull(1)?.toString()
}
"alive" -> {
val newAlive = args.drop(1).mapNotNull { it.toLongOrNullSafe() }.toSet()
val removed = aliveObjects - newAlive
val added = newAlive - aliveObjects
removed.forEach { id ->
objects.remove(id)?.let { objectRemoved[id] = it }
objectAdded.remove(id)
objectUpdated.remove(id)
}
added.forEach { id -> objectAdded.add(id) }
aliveObjects = newAlive
}
"set" -> {
// set s i x y a X Y A m r
if (args.size < 11) return
val sId = args[1].toLongOrNullSafe() ?: return
val cId = args[2].toLongOrNullSafe() ?: 0L
val obj = TuioObject(
sessionId = sId,
classId = cId,
x = args[3].toFloatSafe(),
y = args[4].toFloatSafe(),
angle = args[5].toFloatSafe(),
xVel = args[6].toFloatSafe(),
yVel = args[7].toFloatSafe(),
aVel = args[8].toFloatSafe(),
motionAccel = args[9].toFloatSafe(),
rotationAccel = args[10].toFloatSafe()
)
val existed = objects.containsKey(sId)
objects[sId] = obj
// A session first seen in this frame is an addition only, whether ALIVE or SET named it first.
if (!existed) objectAdded.add(sId) else if (sId !in objectAdded) objectUpdated.add(sId)
}
"fseq" -> {
val fseq = args.getOrNull(1)?.toIntOrNullSafe() ?: return
emitFrame(fseq)
}
}
}
private fun emitFrame(fseq: Int) {
val addedC = cursorAdded.mapNotNull { cursors[it] }
val updatedC = cursorUpdated.mapNotNull { cursors[it] }
val removedC = cursorRemoved.values.toList()
val addedO = objectAdded.mapNotNull { objects[it] }
val updatedO = objectUpdated.mapNotNull { objects[it] }
val removedO = objectRemoved.values.toList()
val frame = Frame(
fseq = fseq,
source = currentSource,
cursors = cursors.values.toList(),
objects = objects.values.toList(),
cursorAdded = addedC,
cursorUpdated = updatedC,
cursorRemoved = removedC,
objectAdded = addedO,
objectUpdated = updatedO,
objectRemoved = removedO
)
// clear per-frame deltas
cursorAdded.clear()
cursorUpdated.clear()
cursorRemoved.clear()
objectAdded.clear()
objectUpdated.clear()
objectRemoved.clear()
listener.onFrame(frame)
}
private fun Any.toLongOrNullSafe(): Long? = when (this) {
is Long -> this
is Int -> this.toLong()
is Short -> this.toLong()
is Byte -> this.toLong()
is Number -> this.toLong()
is String -> this.toLongOrNull()
else -> null
}
private fun Any.toIntOrNullSafe(): Int? = when (this) {
is Int -> this
is Long -> this.toInt()
is Short -> this.toInt()
is Byte -> this.toInt()
is Number -> this.toInt()
is String -> this.toIntOrNull()
else -> null
}
private fun Any.toFloatSafe(): Float = when (this) {
is Float -> this
is Double -> this.toFloat()
is Int -> this.toFloat()
is Long -> this.toFloat()
is Number -> this.toFloat()
is String -> this.toFloatOrNull() ?: 0f
else -> 0f
}
}
fun main()
Tiny demo: prints frame deltas.
fun main() {
val r = TuioReceiver(localPort = 3333, listener = TuioListener { frame ->
println("fseq=${frame.fseq} source=${frame.source}")
frame.cursorAdded.forEach { println(" +cur $it") }
frame.cursorUpdated.forEach { println(" ~cur $it") }
frame.cursorRemoved.forEach { println(" -cur $it") }
frame.objectAdded.forEach { println(" +obj $it") }
frame.objectUpdated.forEach { println(" ~obj $it") }
frame.objectRemoved.forEach { println(" -obj $it") }
})
r.start()
println("Listening for TUIO/UDP on port 3333. Ctrl+C to exit.")
}