Skip to content

OscEditorBackdoor.kt

src/jvmMain/kotlin/afield/app/server/OscEditorBackdoor.kt · 218 lines

package afield.app.server

import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.runtime.execution.plus
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.with

import afield.app.protocol.Message
import afield.app.protocol.OscEditorCommand
import afield.app.protocol._send
import afield.app.protocol.handle
import afield.app.protocol.payload
import afield.audio.synth.*
import afield.document.workspace.FieldPreferences
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.tools.docs.format
import afield.app.diagnostics.logging.lprintln
import com.illposed.osc.OSCMessageEvent
import com.illposed.osc.OSCMessageListener
import com.illposed.osc.messageselector.OSCPatternAddressMessageSelector
import com.illposed.osc.transport.OSCPortIn
import com.illposed.osc.transport.OSCPortInBuilder
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
import kotlin.math.abs

Lets an OSC controller drive the editor by forwarding float, launch and stop messages to the front end as osc-editor commands.

OscEditorBackdoor reads its port, addresses, debounce and delta mode from FieldPreferences, listens with an OSCPortIn, and forwards each matching message to the front end as an osc-editor message. OscEditorBackdoor.learnNextAddress listens once on a port and returns the first address it hears, for filling in the preferences.

The OSC listener settings taken from FieldPreferences; a change restarts the listener.

private data class OscEditorConfig(
    val enabled: Boolean,
    val port: Int,
    val floatAddress: String,
    val launchAddress: String,
    val stopAddress: String,
    val debounceMs: Int,
    val maxExecutionMs: Int,
    val deltaMode: Boolean
)

object OscEditorBackdoor

object OscEditorBackdoor {
    private var current: OscEditorConfig? = null
    private var portIn: OSCPortIn? = null

fun configure(preferences: FieldPreferences)

    @Synchronized
    fun configure(preferences: FieldPreferences) {
        val next = OscEditorConfig(
            enabled = preferences.oscEditorEnabled,
            port = preferences.oscEditorPort.coerceIn(1, 65535),
            floatAddress = preferences.oscEditorFloatAddress.trim(),
            launchAddress = preferences.oscEditorLaunchAddress.trim(),
            stopAddress = preferences.oscEditorStopAddress.trim(),
            debounceMs = preferences.oscEditorDebounceMs.coerceIn(0, 5_000),
            maxExecutionMs = preferences.oscEditorMaxExecutionMs.coerceIn(0, 60_000),
            deltaMode = preferences.oscEditorDeltaMode
        )

        if (next == current) return
        stop()
        current = next

        if (!next.enabled) return
        if (listOf(next.floatAddress, next.launchAddress, next.stopAddress).all { it.isBlank() }) return

        try {
            val created = OSCPortInBuilder()
                .setLocalPort(next.port)
                .build()
            portIn = created
            try {
                created.connect()
            } catch (_: Throwable) {
            }

            addListener(created, next.floatAddress, "float", next)
            addListener(created, next.launchAddress, "launch", next)
            addListener(created, next.stopAddress, "stop", next)
            created.startListening()
            lprintln("info,osc-editor", "listening on UDP ${next.port}")
        } catch (t: Throwable) {
            lprintln("warning,osc-editor", "could not start OSC editor backdoor on UDP ${next.port}: ${t.message}")
            stop()
        }
    }

fun stop()

    @Synchronized
    fun stop() {
        val existing = portIn
        current = null
        portIn = null
        if (existing != null) {
            runCatching { existing.stopListening() }
            runCatching { existing.close() }
        }
    }

suspend fun learnNextAddress(port: Int, timeoutMs: Long = 10_000, ignoreAddresses: Set<String> = emptySet()): String?

body — 103 lines, from line 115
    suspend fun learnNextAddress(port: Int, timeoutMs: Long = 10_000, ignoreAddresses: Set<String> = emptySet()): String? {
        val listenPort = port.coerceIn(1, 65535)
        val timeout = timeoutMs.coerceIn(1_000, 60_000)
        val ignored = ignoreAddresses.mapNotNull { normalizeAddressOrNull(it) }.toSet()
        val learned = CompletableDeferred<String>()
        val listener = OSCMessageListener { event ->
            val address = event.message.address ?: return@OSCMessageListener
            if (address.isBlank()) return@OSCMessageListener
            if (address in ignored) return@OSCMessageListener
            learned.complete(address)
        }
        val tempPort = OSCPortInBuilder()
            .setLocalPort(listenPort)
            .build()

        return try {
            try {
                tempPort.connect()
            } catch (_: Throwable) {
            }
            tempPort.dispatcher.addListener(OSCPatternAddressMessageSelector("//*"), listener)
            tempPort.startListening()
            withTimeoutOrNull(timeout) { learned.await() }
        } finally {
            runCatching { tempPort.stopListening() }
            runCatching { tempPort.close() }
        }
    }

    private fun normalizeAddressOrNull(address: String): String? {
        val trimmed = address.trim()
        return when {
            trimmed.isBlank() -> null
            trimmed.startsWith("/") -> trimmed
            else -> "/$trimmed"
        }
    }

    private fun addListener(port: OSCPortIn, address: String, command: String, config: OscEditorConfig) {
        if (address.isBlank()) return
        if (!address.startsWith("/")) {
            lprintln("warning,osc-editor", "ignoring OSC editor address '$address' because it does not start with /")
            return
        }

        val listener = OSCMessageListener { event ->
            runCatching { handle(event, command, config) }
                .onFailure { lprintln("warning,osc-editor", "OSC editor message failed: ${it.message}") }
        }
        port.dispatcher.addListener(OSCPatternAddressMessageSelector(address), listener)
    }

    private fun handle(event: OSCMessageEvent, command: String, config: OscEditorConfig) {
        val address = event.message.address ?: ""
        if (command == "float") {
            val value = lastNumber(event) ?: return
            _send(
                Message("osc-editor").payload(
                    OscEditorCommand(
                        command = "float",
                        address = address,
                        value = value,
                        text = formatFloat(value),
                        debounceMs = config.debounceMs,
                        maxExecutionMs = config.maxExecutionMs,
                        delta = config.deltaMode
                    )
                )
            )
            return
        }

        val last = lastNumber(event)
        if (last != null && abs(last) <= 1.0e-9) return
        _send(
            Message("osc-editor").payload(
                OscEditorCommand(
                    command = command,
                    address = address,
                    debounceMs = config.debounceMs
                )
            )
        )
    }

    private fun lastNumber(event: OSCMessageEvent): Double? {
        val arguments = event.message.arguments ?: return null
        var found: Double? = null
        for (argument in arguments) {
            when (argument) {
                is Number -> found = argument.toDouble()
                is String -> argument.toDoubleOrNull()?.let { found = it }
            }
        }
        return found
    }

    private fun formatFloat(value: Double): String {
        val symbols = DecimalFormatSymbols(Locale.US)
        val format = DecimalFormat("0.#######", symbols)
        return format.format(value)
    }
}