Syphon.kt
src/jvmMain/kotlin/afield/io/syphon/Syphon.kt · 393 lines
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.io.syphon
import afield.runtime.lifecycle.Runloop
import afield.graphics.vulkan.Renderer
import afield.document.context.fromContext
import afield.graphics.vulkan.targets.RenderToTexture
import afield.media.image.util.PngEncoder
import org.json.JSONArray
import org.json.JSONObject
import org.lwjgl.vulkan.VK10.VK_FORMAT_B8G8R8A8_SRGB
import org.lwjgl.vulkan.VK10.VK_FORMAT_B8G8R8A8_UNORM
import org.lwjgl.vulkan.VK10.VK_FORMAT_R8G8B8A8_SRGB
import org.lwjgl.vulkan.VK10.VK_FORMAT_R8G8B8A8_UNORM
import java.awt.image.BufferedImage
import java.lang.foreign.MemorySegment
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
Field's end of Syphon, the macOS standard for sharing GPU frames between applications: a SyphonServer publishes frames that Resolume, TouchDesigner, MadMapper, OBS (with its Syphon source), VDMX or Max can receive, and a SyphonClient receives what they publish.
val out = syphonServer("Field") // appears in every Syphon client's list
out.publish(image) // a BufferedImage, RGBA bytes, or a RenderToTexture
val inp = syphonClient("Resolume Arena") // a server's name, app name or uuid
display("main", inp.png()) // the latest frame
Field reaches Syphon through a small Objective-C library, native/field-syphon, that is optional
and macOS-only — build it once with ./gradlew buildSyphonNative (needs Xcode).
Every frame crosses the boundary through memory today: a RenderToTexture is read back from the
GPU and copied into Syphon's shared IOSurface, and a received frame is read back into a
ByteArray. At 1920 x 1080 that is about 8 MB a frame each way. Publishing an IOSurface that
already exists (SyphonServer.publishIOSurface) skips the CPU entirely.
object Syphon
Process-wide Syphon state: whether the bridge is built, the server list, and the run-loop pump.
object Syphon {
private val pumping = AtomicBoolean(false)
var mainLoopTurns: Long
Run-loop turns given to Syphon on the process's main thread (see ensurePump).
@Volatile
var mainLoopTurns: Long = 0
private set
val cpuCopies: Long get() = SyphonNative.cpuCopies.get()
Frames this JVM has read back through memory from any Syphon client (latest(), png(), and
a SyphonTexture on its copy path). A zero-copy SyphonTexture leaves it unchanged.
var offMainCalls: Long
Pump calls that found themselves off the main thread, and so did nothing.
@Volatile
var offMainCalls: Long = 0
private set
internal fun ensurePump()
Syphon announces and discovers servers through macOS distributed notifications, which arrive
on the process's main run loop. Field's main thread runs Field's own loop instead (it starts
with -XstartOnFirstThread), so this hooks a non-blocking run-loop turn into every
Runloop frame. Without it Field's servers are found only by clients that were already
listening when they started, and Field never sees other applications' servers.
internal fun ensurePump() {
if (!pumping.compareAndSet(false, true)) return
Runloop.runLoop.preRun["syphon-pump"] = sequence {
while (true) {
runCatching { if (SyphonNative.pump(0)) mainLoopTurns++ else offMainCalls++ }
yield(null)
}
}
}
val isAvailable: Boolean get() = SyphonLibrary.isAvailable
Whether the native bridge is built (and this is a Mac).
fun describe(): String = SyphonLibrary.describe()
A sentence saying where the bridge is, or how to build it.
internal fun requireLibrary()
internal fun requireLibrary() {
check(SyphonLibrary.isAvailable) { SyphonLibrary.describe() }
ensurePump()
}
fun servers(waitMs: Int = 500): List<SyphonServerInfo>
Every Syphon server announced on this machine — Field's own included. The first call waits
up to waitMs for announcements to arrive.
fun servers(waitMs: Int = 500): List<SyphonServerInfo> {
requireLibrary()
val a = JSONArray(SyphonNative.list(waitMs))
return (0 until a.length()).map { SyphonServerInfo.of(a.getJSONObject(it)) }
}
internal val open = ConcurrentHashMap<String, SyphonServer>()
internal val clients = ConcurrentHashMap<String, SyphonClient>()
data class SyphonServerInfo(val name: String, val appName: String, val uuid: String)
One announced server: what SyphonClient can connect to.
companion object
internal fun of(o: JSONObject)
internal fun of(o: JSONObject) = SyphonServerInfo(o.optString("name"), o.optString("appName"), o.optString("uuid"))
}
override fun toString(): String = if (name.isEmpty()) appName else "$appName — $name"
class SyphonServer internal constructor(val name: String) : AutoCloseable
A Syphon server: frames published here are visible to every Syphon client on the machine.
Make one with syphonServer, which returns the open server of that name rather than a second one, so re-running a box keeps publishing to the same source. A server holds native resources until close.
class SyphonServer internal constructor(val name: String) : AutoCloseable {
private val handle: MemorySegment = SyphonNative.serverNew(name)
private val cleanable = SyphonNative.cleaner.register(this, SyphonNative.Free(handle, SyphonNative::serverFree))
@Volatile private var closed = false
@Volatile private var following: RenderToTexture? = null
var framesPublished: Long
Frames published so far.
@Volatile
var framesPublished: Long = 0
private set
val info: SyphonServerInfo get()
This server as other applications see it.
val info: SyphonServerInfo get() = SyphonServerInfo.of(JSONObject(SyphonNative.serverDescription(handle)))
val hasClients: Boolean get() = !closed && SyphonNative.hasClients(handle)
Whether any application is receiving.
private fun live() = check(!closed) { "Syphon server \"$name\" is closed" }
fun publish(pixels: ByteArray, width: Int, height: Int, bgra: Boolean = false)
Publish width x height pixels, four bytes each, top row first: RGBA order, or BGRA when
bgra is true (what a B8G8R8A8 render target reads back as). The bytes are copied; reuse
the array at once.
@JvmOverloads
fun publish(pixels: ByteArray, width: Int, height: Int, bgra: Boolean = false) {
live()
synchronized(this) { SyphonNative.publish(handle, pixels, width, height, bgra) }
framesPublished++
}
fun publish(argb: IntArray, width: Int, height: Int)
Publish ARGB ints (as BufferedImage.getRGB and afield.media.image.util.PngEncoder use), top row first.
fun publish(argb: IntArray, width: Int, height: Int) {
require(argb.size >= width * height) { "need ${width * height} pixels, got ${argb.size}" }
val bgra = ByteArray(width * height * 4)
for (i in 0 until width * height) {
val p = argb[i]
bgra[i * 4] = p.toByte()
bgra[i * 4 + 1] = (p shr 8).toByte()
bgra[i * 4 + 2] = (p shr 16).toByte()
bgra[i * 4 + 3] = (p ushr 24).toByte()
}
publish(bgra, width, height, bgra = true)
}
fun publish(image: BufferedImage)
Publish a BufferedImage (any type; it is converted to ARGB).
fun publish(image: BufferedImage) {
val w = image.width
val h = image.height
publish(image.getRGB(0, 0, w, h, null, 0, w), w, h)
}
fun publish(target: RenderToTexture)
Publish the next frame target renders: its colour attachment is read back once that frame's
GPU work completes, then handed to Syphon. Returns at once. The target needs 8-bit RGBA or
BGRA colour (the default B8G8R8A8_SRGB is fine) and colorTransferSrc = true (the
default).
fun publish(target: RenderToTexture) {
live()
val bgra = bgraOf(target)
val w = target.parameters.width
val h = target.parameters.height
target.readPixelsAsync().thenAccept { bytes -> if (!closed) publish(bytes, w, h, bgra) }
}
fun follow(target: RenderToTexture)
Publish every frame target renders, until stopFollowing, close, or another call to
follow. One readback is in flight at a time, so a slow consumer drops frames instead of
queueing them.
fun follow(target: RenderToTexture) {
live()
val bgra = bgraOf(target)
gpu?.close()
gpu = null
following = target
fun next() {
if (closed || following !== target) return
val w = target.parameters.width
val h = target.parameters.height
target.readPixelsAsync().whenComplete { bytes, err ->
if (err == null && bytes != null && !closed && following === target) {
runCatching { publish(bytes, w, h, bgra) }
}
if (err == null) next()
}
}
next()
}
fun stopFollowing()
Stop following and followOnGpu.
fun stopFollowing() {
following = null
gpu?.close()
gpu = null
}
@Volatile private var gpu: SyphonGpuPublisher? = null
fun followOnGpu(target: RenderToTexture, renderer: Renderer = fromContext()): SyphonGpuPublisher
Publish every frame target renders without a CPU readback: each frame is copied on the GPU
into an IOSurface and Syphon takes it from there. Needs a B8G8R8A8 target (the default) and
a driver with VK_EXT_external_memory_host (KosmicKrisp and MoltenVK on Apple silicon); call
it after target is in the renderer's list, since it renders after whatever is already there.
Replaces any earlier follow or followOnGpu; stopFollowing ends it.
@JvmOverloads
fun followOnGpu(target: RenderToTexture, renderer: Renderer = fromContext()): SyphonGpuPublisher {
live()
stopFollowing()
val p = SyphonGpuPublisher(this, target, renderer)
val key = "syphon:$name"
// Remove first so the key goes to the end of the (insertion-ordered) list, after the target.
renderer.renderList.others.remove(key)
renderer.renderList.others[key] = p
gpu = p
return p
}
fun publishIOSurface(ioSurfaceRef: Long, flipped: Boolean = false)
Publish an existing BGRA8 IOSurface by its IOSurfaceRef address, without touching the CPU:
Syphon copies it GPU-side into its own shared surface. flipped says the surface's first row
is the bottom of the picture.
@JvmOverloads
fun publishIOSurface(ioSurfaceRef: Long, flipped: Boolean = false) {
live()
synchronized(this) { SyphonNative.publishSurface(handle, ioSurfaceRef, flipped) }
framesPublished++
}
override fun close()
Stop publishing; clients see the server disappear.
override fun close() {
if (closed) return
closed = true
following = null
gpu?.close()
gpu = null
Syphon.open.remove(name, this)
synchronized(this) { cleanable.clean() }
}
override fun toString(): String
override fun toString(): String = "SyphonServer(\"$name\", ${if (closed) "closed" else "$framesPublished frames"})"
private fun bgraOf(target: RenderToTexture): Boolean = when (target.parameters.format) {
VK_FORMAT_B8G8R8A8_SRGB, VK_FORMAT_B8G8R8A8_UNORM -> true
VK_FORMAT_R8G8B8A8_SRGB, VK_FORMAT_R8G8B8A8_UNORM -> false
else -> throw IllegalArgumentException(
"Syphon publishes 8-bit RGBA/BGRA; this RenderToTexture's format is ${target.parameters.format}")
}
}
class SyphonClient internal constructor(private val handle: MemorySegment) : AutoCloseable
A connection to another application's Syphon server. latest returns its most recent frame. Holds native resources until close.
class SyphonClient internal constructor(private val handle: MemorySegment) : AutoCloseable {
private val cleanable = SyphonNative.cleaner.register(this, SyphonNative.Free(handle, SyphonNative::clientFree))
@Volatile private var closed = false
val isClosed: Boolean get() = closed
Whether this client's native connection has been released.
private var buffer: ByteArray? = null
val server: SyphonServerInfo
The server this client is connected to.
val server: SyphonServerInfo = SyphonServerInfo.of(JSONObject(SyphonNative.clientDescription(handle)))
val frameCount: Long get() = if (closed) 0 else SyphonNative.frameCount(handle)
Frames the server has sent since this client connected.
fun waitForFrame(after: Long = frameCount, waitMs: Int = 1000): Long
Wait up to waitMs for a frame after the afterth; returns the frame count then.
@JvmOverloads
fun waitForFrame(after: Long = frameCount, waitMs: Int = 1000): Long = SyphonNative.waitFrame(handle, after, waitMs)
fun latest(): SyphonFrame?
The latest frame, or null if none has arrived yet.
fun latest(): SyphonFrame? {
check(!closed) { "Syphon client is closed" }
val (bytes, w, h) = synchronized(this) { SyphonNative.copyRgba(handle, null) } ?: return null
return SyphonFrame(bytes, w, h)
}
internal fun handleForTexture(): MemorySegment? = if (closed) null else handle
internal fun copyInto(dst: MemorySegment): Pair<Int, Int>?
internal fun copyInto(dst: MemorySegment): Pair<Int, Int>? =
if (closed) null else synchronized(this) { SyphonNative.copyRgbaInto(handle, dst) }
fun texture(renderer: Renderer = fromContext(), waitMs: Int = 2000, zeroCopy: Boolean = true): SyphonTexture
This client's frames as a Vulkan texture that follows the server: bind the returned
SyphonTexture as a sampler2D like any other texture. Where the Vulkan driver allows it the
shader samples the sender's IOSurface itself, with nothing copied (SyphonTexture.path);
zeroCopy = false asks for the old route through memory. Waits up to waitMs for a first
frame. Registered in renderer's list, which is what refreshes it each frame.
@JvmOverloads
fun texture(renderer: Renderer = fromContext(), waitMs: Int = 2000, zeroCopy: Boolean = true): SyphonTexture {
check(!closed) { "Syphon client is closed" }
if (frameCount == 0L) waitForFrame(0, waitMs)
if (frameCount == 0L) throw IllegalStateException("Syphon: no frame from $server within $waitMs ms")
val t = SyphonTexture(this, renderer, zeroCopy)
val key = "syphon-in:${server.uuid}"
(renderer.renderList.others.remove(key) as? SyphonTexture)?.close()
renderer.renderList.others[key] = t
return t
}
fun png(): ByteArray? = latest()?.png()
The latest frame as a PNG (for display), or null if none has arrived yet.
override fun close()
override fun close() {
if (closed) return
closed = true
synchronized(this) { cleanable.clean() }
Syphon.clients.entries.removeIf { it.value === this }
}
override fun toString(): String
override fun toString(): String = "SyphonClient($server, ${if (closed) "closed" else "$frameCount frames"})"
}
class SyphonFrame(val rgba: ByteArray, val width: Int, val height: Int)
One received frame: rgba is width x height x 4 bytes, top row first.
class SyphonFrame(val rgba: ByteArray, val width: Int, val height: Int) {
fun rgbaAt(x: Int, y: Int): IntArray
The RGBA of pixel (x, y), y down from the top, as 0..255 ints.
fun rgbaAt(x: Int, y: Int): IntArray {
val i = (y * width + x) * 4
return IntArray(4) { rgba[i + it].toInt() and 0xff }
}
fun argb(): IntArray
ARGB ints, one per pixel, top row first.
fun argb(): IntArray = IntArray(width * height) { i ->
val o = i * 4
((rgba[o + 3].toInt() and 0xff) shl 24) or ((rgba[o].toInt() and 0xff) shl 16) or
((rgba[o + 1].toInt() and 0xff) shl 8) or (rgba[o + 2].toInt() and 0xff)
}
fun image(): BufferedImage
fun image(): BufferedImage = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB).also {
it.setRGB(0, 0, width, height, argb(), 0, width)
}
fun png(): ByteArray = PngEncoder.encode(argb(), width, height)
override fun toString(): String = "SyphonFrame($width x $height)"
fun syphonServer(name: String = "Field"): SyphonServer
The open Syphon server called name, or a new one. Other applications list it under Field's
app name and this name.
fun syphonServer(name: String = "Field"): SyphonServer {
Syphon.requireLibrary()
return Syphon.open.compute(name) { _, existing -> existing ?: SyphonServer(name) }!!
}
fun syphonClient(match: String = "", waitMs: Int = 2000): SyphonClient
Connect to the first server whose uuid, name, or app name (tried in that order) is match; an
empty match takes the first server announced. Waits up to waitMs for it to appear, then
throws, listing the servers it could see. Reuses the open client cached under exactly match;
a missing or closed client is recreated. Calls with different matches have separate cache slots.
Clients persist across box runs; close one explicitly when its shared connection is no longer needed.
@JvmOverloads
fun syphonClient(match: String = "", waitMs: Int = 2000): SyphonClient {
Syphon.requireLibrary()
require(waitMs >= 0) { "waitMs must be non-negative" }
return Syphon.clients.compute(match) { _, existing ->
if (existing != null && !existing.isClosed) existing else {
val h = SyphonNative.clientNew(match, waitMs) ?: run {
val why = SyphonNative.lastErrorMessage()
val seen = runCatching { Syphon.servers(0) }.getOrDefault(emptyList())
throw IllegalStateException("Syphon: $why. Announced: ${if (seen.isEmpty()) "none" else seen.joinToString()}")
}
SyphonClient(h)
}
}!!
}