OscQueryBox.kt
src/jvmMain/kotlin/afield/io/osc/query/OscQueryBox.kt · 249 lines
@file:afield.runtime.completion.CompletionCategory("Networking")
package afield.io.osc.query
import afield.document.graph.Box
import afield.ui.output.ControlGroupBuilder
import afield.ui.output.ControlValueHandle
import afield.runtime.lifecycle.Runloop
import afield.ui.output.controlGroup
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
The box-facing OSCQuery entry points: Box.oscQuery to publish parameters, Box.oscQueryRemote
and Box.oscQueryDiscover to reach other services, and Box.oscQueryMirror to show a remote's
namespace as Field controls.
OscQueryServices keeps one server per HTTP port across box runs and brings callbacks onto the main Runloop; BoxOscQuery is the per-box view that owns the parameters a box declares.
object OscQueryServices
Process-wide OSCQuery state behind Box.oscQuery: one OscQueryServer per HTTP port, the
remotes a box has opened, and the queue that brings callbacks onto the main Runloop.
Lifecycle follows afield.io.mqtt: servers survive a box being stopped (TouchDesigner should not
lose its connection because you stopped the box that owns a parameter) and re-running a box
replaces its parameters in place, keeping their values. Each parameter is owned by the box that
declared it as ReleasePolicy.KEYED, so deleting the box removes it from the namespace (and
remotes see PATH_REMOVED). closeAll shuts everything down.
object OscQueryServices {
const val DEFAULT_HTTP_PORT = 9020
HTTP port Box.oscQuery uses when none is given; OSC UDP is the next port up.
private val servers = ConcurrentHashMap<Int, OscQueryServer>()
private val remotes = ConcurrentHashMap<String, OscQueryRemote>()
private val owned = ConcurrentHashMap.newKeySet<String>()
private val queue = ConcurrentLinkedQueue<Runnable>()
@Volatile private var drainInstalled = false
fun dispatch(r: Runnable)
Runs r on the main Runloop inside Field, directly where no Runloop turns (tests, jvm-main).
fun dispatch(r: Runnable) {
if (Runloop.mainThread == null) return r.run()
queue.add(r)
if (!drainInstalled) synchronized(this) {
if (!drainInstalled) {
drainInstalled = true
Runloop.runLoop.preRun.setUnowned("afield.oscquery.dispatch", sequence {
while (true) {
var n = 0
while (n++ < 2000) {
val next = queue.poll() ?: break
try { next.run() } catch (t: Throwable) { afield.app.diagnostics.logging.lprintln("error,oscquery", "callback threw: $t") }
}
yield(null)
}
})
}
}
}
fun server(name: String, httpPort: Int, oscPort: Int, advertise: Boolean): OscQueryServer
fun server(name: String, httpPort: Int, oscPort: Int, advertise: Boolean): OscQueryServer =
servers.compute(httpPort) { _, existing ->
if (existing != null && !existing.isClosed) existing
else OscQueryServer(name, httpPort, oscPort, advertise = advertise, dispatch = ::dispatch)
}!!
fun remote(host: String, port: Int): OscQueryRemote
fun remote(host: String, port: Int): OscQueryRemote =
remotes.compute("$host:$port") { _, existing -> existing?.refresh() ?: OscQueryRemote(host, port, ::dispatch) }!!
internal fun own(box: Box, server: OscQueryServer, path: String)
internal fun own(box: Box, server: OscQueryServer, path: String) {
val key = "${box.id}|${server.httpPort}|$path"
if (!owned.add(key)) return
box.runScopes.parent.own(afield.runtime.lifecycle.ReleasePolicy.KEYED) {
if (owned.remove(key)) server.remove(path)
}
}
fun closeAll()
Close every server and remote. Their parameters are gone; boxes re-create them when re-run.
fun closeAll() {
servers.values.forEach { it.close() }; servers.clear()
remotes.values.forEach { it.close() }; remotes.clear()
owned.clear()
}
}
class BoxOscQuery internal constructor(private val box: Box, val server: OscQueryServer)
A box's view of an OSCQuery service: declare parameters, and their callbacks run in this box's context on the main Runloop when TouchDesigner, Max, Chataigne or VDMX set them.
val q = oscQuery("My piece")
q.float("/synth/cutoff", 20.0..20000.0, 1000.0) { cutoff = it }
q.bool("/synth/on", true) { on = it }
Re-running the box replaces each parameter's callback and keeps its current value. Values set from
Field with OscQueryParam.set are pushed to every remote that LISTENs.
class BoxOscQuery internal constructor(private val box: Box, val server: OscQueryServer) {
val url: String get()
http://<host>:<port>/, the URL a remote browses.
val url: String get() = "http://${java.net.InetAddress.getLocalHost().hostName}:${server.httpPort}/"
val httpPort: Int get() = server.httpPort
val oscPort: Int get() = server.oscPort
fun param
A parameter of any OSC type ("f", "ii", "s", "T", ...). onSet gets every value a
remote sends, coerced to type and clipped to range.
fun param(
path: String,
type: String,
initial: List<Any?>,
range: List<Pair<Double?, Double?>> = emptyList(),
choices: List<Any?> = emptyList(),
access: OscAccess = OscAccess.READ_WRITE,
description: String? = null,
onSet: ((List<Any?>) -> Unit)? = null,
): OscQueryParam {
val p = server.add(path, type, initial, range, choices, access, description)
if (onSet != null) p.onSet { v -> Box.withBoxContextNow(box) { try { onSet(v) } catch (t: Throwable) { box.println("oscQuery '${p.path}': $t") } } }
OscQueryServices.own(box, server, p.path)
return p
}
fun float(path: String, range: ClosedFloatingPointRange<Double> = 0.0..1.0, initial: Double = range.start,
A float over range.
fun float(path: String, range: ClosedFloatingPointRange<Double> = 0.0..1.0, initial: Double = range.start,
description: String? = null, access: OscAccess = OscAccess.READ_WRITE, onSet: ((Double) -> Unit)? = null): OscQueryParam =
param(path, "f", listOf(initial), listOf(range.start to range.endInclusive), access = access, description = description,
onSet = onSet?.let { f -> { v: List<Any?> -> f((v[0] as Number).toDouble()) } })
fun int(path: String, range: IntRange? = null, initial: Int = range?.first ?: 0,
An int over range.
fun int(path: String, range: IntRange? = null, initial: Int = range?.first ?: 0,
description: String? = null, onSet: ((Int) -> Unit)? = null): OscQueryParam =
param(path, "i", listOf(initial), range?.let { listOf(it.first.toDouble() to it.last.toDouble()) } ?: emptyList(),
description = description, onSet = onSet?.let { f -> { v: List<Any?> -> f((v[0] as Number).toInt()) } })
fun bool(path: String, initial: Boolean = false, description: String? = null, onSet: ((Boolean) -> Unit)?
A boolean (OSC T/F): a toggle in TouchDesigner and Chataigne.
fun bool(path: String, initial: Boolean = false, description: String? = null, onSet: ((Boolean) -> Unit)? = null): OscQueryParam =
param(path, "T", listOf(initial), description = description,
onSet = onSet?.let { f -> { v: List<Any?> -> f(v[0] as Boolean) } })
fun string(path: String, initial: String = "", description: String? = null, onSet: ((String) -> Unit)?
A string.
fun string(path: String, initial: String = "", description: String? = null, onSet: ((String) -> Unit)? = null): OscQueryParam =
param(path, "s", listOf(initial), description = description, onSet = onSet?.let { f -> { v: List<Any?> -> f(v[0].toString()) } })
fun menu(path: String, choices: List<String>, initial: String = choices.first(), description: String? = null,
A string limited to choices (RANGE: [{"VALS": ...}]), which remotes show as a menu.
fun menu(path: String, choices: List<String>, initial: String = choices.first(), description: String? = null,
onSet: ((String) -> Unit)? = null): OscQueryParam =
param(path, "s", listOf(initial), choices = choices, description = description,
onSet = onSet?.let { f -> { v: List<Any?> -> f(v[0].toString()) } })
fun vector(path: String, initial: List<Double>, range: ClosedFloatingPointRange<Double> = 0.0..1.0,
An n-float vector, each over range: "ff" for an XY pad, "fff" for a colour.
fun vector(path: String, initial: List<Double>, range: ClosedFloatingPointRange<Double> = 0.0..1.0,
description: String? = null, onSet: ((List<Double>) -> Unit)? = null): OscQueryParam =
param(path, "f".repeat(initial.size), initial, List(initial.size) { range.start to range.endInclusive }, description = description,
onSet = onSet?.let { f -> { v: List<Any?> -> f(v.map { (it as Number).toDouble() }) } })
fun describe(path: String, description: String) = server.describe(path, description)
Describe a container (/synth), shown by remote browsers.
operator fun get(path: String): OscQueryParam? = server[path]
fun controls(prefix: String = "/" + box.name.ifBlank { box.id }, channel: String = "controls", block: OscControls.() -> Unit) {
Field controls that are also OSCQuery parameters: each slider or checkbox appears in this box's output and in the namespace under prefix. Dragging it in Field runs the callback and tells every listening remote; a remote setting it moves the Field control and runs the callback.
fun controls(prefix: String = "/" + box.name.ifBlank { box.id }, channel: String = "controls", block: OscControls.() -> Unit) {
val base = "/" + prefix.trim('/')
box.controlGroup(channel) { OscControls(this@BoxOscQuery, this, base).block() }
}
class OscControls internal constructor(private val q: BoxOscQuery, private val group: ControlGroupBuilder, private val base: String)
The builder controls hands its block: ControlGroupBuilder's controls, published.
class OscControls internal constructor(private val q: BoxOscQuery, private val group: ControlGroupBuilder, private val base: String) {
private fun pathOf(label: String) = "$base/" + label.trim().replace(Regex("[\\s#*,?\\[\\]{}]+"), "_")
fun slider(label: String, min: Double = 0.0, max: Double = 1.0, step: Double = 0.01, initialValue: Double = (min + max) / 2,
fun slider(label: String, min: Double = 0.0, max: Double = 1.0, step: Double = 0.01, initialValue: Double = (min + max) / 2,
description: String? = null, onUpdate: (Double) -> Unit): ControlValueHandle<Double> {
lateinit var h: ControlValueHandle<Double>
// The param first: a cached control runs its callback while it is being built.
val param = q.float(pathOf(label), min..max, initialValue, description) { v -> h.set(v); onUpdate(v) }
h = group.slider(label, min, max, step, initialValue) { v -> param.set(v); onUpdate(v) }
return h
}
fun checkbox(label: String, initialValue: Boolean = false, description: String? = null, onUpdate: (Boolean) -> Unit): ControlValueHandle<Boolean> {
fun checkbox(label: String, initialValue: Boolean = false, description: String? = null, onUpdate: (Boolean) -> Unit): ControlValueHandle<Boolean> {
lateinit var h: ControlValueHandle<Boolean>
val param = q.bool(pathOf(label), initialValue, description) { v -> h.set(v); onUpdate(v) }
h = group.checkbox(label, initialValue) { v -> param.set(v); onUpdate(v) }
return h
}
}
}
fun Box.oscQuery
This box's OSCQuery service on httpPort (OSC UDP on oscPort), advertised over DNS-SD as
name. The first call for a port starts it; later calls, from any box, share it.
fun Box.oscQuery(
name: String = "Field",
httpPort: Int = OscQueryServices.DEFAULT_HTTP_PORT,
oscPort: Int = if (httpPort == 0) 0 else httpPort + 1,
advertise: Boolean = true,
): BoxOscQuery = BoxOscQuery(this, OscQueryServices.server(name, httpPort, oscPort, advertise))
fun Box.oscQueryRemote(host: String, httpPort: Int): OscQueryRemote
A remote OSCQuery service at host:httpPort, cached so re-running a box reuses its
connection. A cached remote re-reads the namespace when it is asked for again, so params lists
what the service has now, not what it had when the first box connected. Callbacks (listen)
run on the main Runloop.
fun Box.oscQueryRemote(host: String, httpPort: Int): OscQueryRemote = OscQueryServices.remote(host, httpPort)
fun Box.oscQueryDiscover(timeoutMs: Long = 1500): List<Mdns.Service>
OSCQuery services on the local network, found over DNS-SD (_oscjson._tcp) for timeoutMs.
fun Box.oscQueryDiscover(timeoutMs: Long = 1500): List<Mdns.Service> = OscQueryRemote.discover(timeoutMs)
fun Box.oscQueryMirror(remote: OscQueryRemote, channel: String = "controls", filter: (OscQueryRemote.Node) -> Boolean
Field controls mirroring every numeric and boolean parameter of remote: a slider for each float
or int with a range, a checkbox for each boolean. Moving one sets the remote; when the remote
supports LISTEN, its own changes move the Field control.
fun Box.oscQueryMirror(remote: OscQueryRemote, channel: String = "controls", filter: (OscQueryRemote.Node) -> Boolean = { true }) {
val nodes = remote.params.filter(filter)
controlGroup(channel) {
for (n in nodes) {
val t = n.type
when {
t == "T" || t == "F" -> {
val h = checkbox(n.path, n.value.firstOrNull() == true) { v -> n.set(v) }
if (remote.supportsListen) n.listen { v -> (v.firstOrNull() as? Boolean)?.let { h.set(it) } }
}
(t == "f" || t == "i" || t == "d" || t == "h") -> {
val (lo, hi) = n.range.firstOrNull() ?: (null to null)
val min = lo ?: 0.0
val max = hi ?: maxOf(1.0, n.number)
val step = if (t == "i" || t == "h") 1.0 else (max - min) / 1000.0
val h = slider(n.path, min, max, step, n.number.coerceIn(min, max)) { v -> n.set(v) }
if (remote.supportsListen) n.listen { v -> (v.firstOrNull() as? Number)?.let { h.set(it.toDouble()) } }
}
}
}
}
}