Skip to content

AbletonLinkAudio.kt

src/jvmMain/kotlin/afield/audio/synth/AbletonLinkAudio.kt · 398 lines

@file:afield.runtime.completion.CompletionCategory("Audio")
package afield.audio.synth

import afield.data.query.graph.LiveGraphEdge
import afield.data.query.graph.LiveGraphInspectable
import com.ableton.link.AbletonLink
import com.ableton.link.LinkAudioChannel
import com.ableton.link.LinkAudioSink
import com.ableton.link.LinkAudioSource
import com.ableton.link.SessionState
import java.util.concurrent.ConcurrentHashMap

Ableton Link from inside Field: a tempo, beat and phase shared with every Link peer on the network, and audio published to and taken from them.

LinkAudioInterface is the whole surface and there is one of it per process. shared opens it or hands back the one already open, currentOrNull asks whether there is one without opening one, and destroyShared closes it so that the next shared builds a new one. Boxes usually arrive through abletonLink, linkTiming, linkAudioOut, linkAudioIn and linkAudioInStereo in synth.kt, none of which ask the caller for an interface.

The timing half is a session state every peer on the network shares. setTempo, setPlaying and requestBeat write into it, enableStartStopSync decides whether transport start and stop travel with it, and LinkTimingSnapshot is the read: tempo, beat, phase, peer count and Link's own microsecond clock, all taken at one instant. FieldAudioEngine takes a snapshot per audio block and hangs it on the SynthContext every Signal renders against, which is where linkBeat, linkPhase and linkTempo come from. ParamScope re-exposes the same values, so a Param expression can read them too.

The audio half is Link Audio, which carries sound between peers of that same session. enableAudioOutput publishes a named channel and FieldAudioEngine.enableLinkOutput is what points the mix at it; channels lists the channels the session is carrying, and monoInput and stereoInput subscribe to one of them and hand back a Signal the graph uses like any other source. Sonic Pi's link_audio reads a channel published this way, and Sonic Pi covers that end of it, including the Link visibility setting that has to be on before either side can see the other.

There are two SessionStates here because Link has two. One is captured and committed under a lock for calls arriving from a box or the UI; the other is captured inside the audio block, so that the beat the block starts on and the buffer rendered for it are committed together. linkAudioCommits, linkAudioCommitFailures and linkAudioInvalidBuffers count how that goes, and they are live-graph children along with the peer count and the channel list, so an open interface can be walked by a live-graph query.

None of the Link bindings are Kotlin — com.ableton.link comes from libs/ableton-link-ffm.jar, over a native Link library the build points at with the ableton.link.library system property.

data class LinkTimingSnapshot

data class LinkTimingSnapshot(
    val enabled: Boolean,
    val audioEnabled: Boolean,
    val peers: Long,
    val tempo: Double,
    val beat: Double,
    val phase: Double,
    val quantum: Double,
    val clockMicros: Long,
    val playing: Boolean
) {

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this,
        "enabled" to enabled,
        "audioEnabled" to audioEnabled,
        "peers" to peers,
        "tempo" to tempo,
        "beat" to beat,
        "phase" to phase,
        "quantum" to quantum,
        "clockMicros" to clockMicros,
        "playing" to playing
    )
}

class LinkAudioInterface private constructor

class LinkAudioInterface private constructor(
    tempo: Double,
    peerName: String,
    val quantum: Double
) : AutoCloseable, LiveGraphInspectable {

companion object

    companion object {
        @Volatile
        private var sharedInstance: LinkAudioInterface? = null

fun shared

        @Synchronized
        fun shared(
            tempo: Double = 120.0,
            peerName: String = "Field4",
            quantum: Double = 4.0
        ): LinkAudioInterface {
            val existing = sharedInstance
            if (existing != null) {
                existing.setPeerName(peerName)
                return existing
            }
            return LinkAudioInterface(tempo, peerName, quantum).also {
                sharedInstance = it
            }
        }

fun currentOrNull(): LinkAudioInterface? = sharedInstance?.takeUnless { it.closed }

fun destroyShared()

        @Synchronized
        fun destroyShared() {
            sharedInstance?.close()
            sharedInstance = null
        }
    }

    private val link = AbletonLink(tempo)
    private val appState = SessionState()
    private val audioState = SessionState()
    private val inputStates = ConcurrentHashMap<String, LinkCaptureState>()
    private val inputSources = ConcurrentHashMap<String, LinkAudioSource>()

    @Volatile
    private var sink: LinkAudioSink? = null

    @Volatile
    private var sinkChannels: Int = 2

var linkAudioCommits: Long

    @Volatile
    var linkAudioCommits: Long = 0L
        private set

var linkAudioCommitFailures: Long

    @Volatile
    var linkAudioCommitFailures: Long = 0L
        private set

var linkAudioInvalidBuffers: Long

    @Volatile
    var linkAudioInvalidBuffers: Long = 0L
        private set

var closed: Boolean

    @Volatile
    var closed: Boolean = false
        private set

init

    init {
        link.setPeerName(peerName)
        link.enable(true)
    }

val enabled: Boolean

    val enabled: Boolean
        get() = !closed && link.isEnabled

val audioEnabled: Boolean

    val audioEnabled: Boolean
        get() = !closed && link.isLinkAudioEnabled

val peers: Long

    val peers: Long
        get() = if (closed) 0L else link.numPeers()

fun setPeerName(name: String): LinkAudioInterface

    fun setPeerName(name: String): LinkAudioInterface = apply {
        if (!closed && name.isNotBlank()) link.setPeerName(name)
    }

fun setTempo(tempo: Double): LinkAudioInterface

    fun setTempo(tempo: Double): LinkAudioInterface = apply {
        if (closed) return@apply
        val now = link.clockMicros()
        synchronized(appState) {
            link.captureAppSessionState(appState)
            appState.setTempo(tempo, now)
            link.commitAppSessionState(appState)
        }
    }

fun setPlaying(playing: Boolean): LinkAudioInterface

    fun setPlaying(playing: Boolean): LinkAudioInterface = apply {
        if (closed) return@apply
        val now = link.clockMicros()
        synchronized(appState) {
            link.captureAppSessionState(appState)
            appState.setIsPlaying(playing, now)
            link.commitAppSessionState(appState)
        }
    }

fun enableStartStopSync(enabled: Boolean = true): LinkAudioInterface

    fun enableStartStopSync(enabled: Boolean = true): LinkAudioInterface = apply {
        if (!closed) link.enableStartStopSync(enabled)
    }

fun requestBeat(beat: Double, quantum: Double = this.quantum): LinkAudioInterface

    fun requestBeat(beat: Double, quantum: Double = this.quantum): LinkAudioInterface = apply {
        if (closed) return@apply
        val now = link.clockMicros()
        synchronized(appState) {
            link.captureAppSessionState(appState)
            appState.requestBeatAtTime(beat, now, quantum)
            link.commitAppSessionState(appState)
        }
    }

fun snapshot(quantum: Double = this.quantum): LinkTimingSnapshot

    fun snapshot(quantum: Double = this.quantum): LinkTimingSnapshot {
        if (closed) {
            return LinkTimingSnapshot(false, false, 0L, 0.0, 0.0, 0.0, quantum, 0L, false)
        }
        synchronized(appState) {
            link.captureAppSessionState(appState)
            return snapshotFrom(appState, quantum)
        }
    }

internal fun beginAudioBlock(quantum: Double = this.quantum): LinkAudioBlock?

    @Synchronized
    internal fun beginAudioBlock(quantum: Double = this.quantum): LinkAudioBlock? {
        if (closed) return null
        link.captureAudioSessionState(audioState)
        val now = link.clockMicros()
        val snapshot = snapshotFrom(audioState, quantum, now)
        return LinkAudioBlock(audioState, snapshot, snapshot.beat)
    }

internal fun commitStereo(block: LinkAudioBlock?, samples: ShortArray, frames: Int, sampleRate: Int)

    @Synchronized
    internal fun commitStereo(block: LinkAudioBlock?, samples: ShortArray, frames: Int, sampleRate: Int) {
        val activeSink = sink ?: return
        if (closed || block == null) return
        activeSink.retainBuffer().use { buffer ->
            if (!buffer.isValid) {
                linkAudioInvalidBuffers += 1
                return
            }
            val sampleCount = minOf(samples.size, frames * 2)
            if (sampleCount > buffer.maxNumSamples()) {
                linkAudioCommitFailures += 1
                return
            }
            buffer.copyFrom(samples, 0, sampleCount)
            val committed = buffer.commit(block.state, block.beginBeat, block.snapshot.quantum, frames.toLong(), 2L, sampleRate)
            if (committed) {
                linkAudioCommits += 1
            } else {
                linkAudioCommitFailures += 1
            }
        }
    }

fun enableAudioOutput(name: String = "Field4", maxFrames: Int = 4096, channels: Int = 2): LinkAudioInterface

    @Synchronized
    fun enableAudioOutput(name: String = "Field4", maxFrames: Int = 4096, channels: Int = 2): LinkAudioInterface = apply {
        if (closed) return@apply
        link.enableLinkAudio(true)
        val sampleCapacity = maxFrames.toLong() * channels.coerceAtLeast(1).toLong()
        sinkChannels = channels.coerceAtLeast(1)
        val active = sink
        if (active == null) {
            sink = link.createAudioSink(name, sampleCapacity)
        } else {
            active.close()
            sink = link.createAudioSink(name, sampleCapacity)
        }
    }

fun disableAudioOutput(): LinkAudioInterface

    @Synchronized
    fun disableAudioOutput(): LinkAudioInterface = apply {
        sink?.close()
        sink = null
        if (inputSources.isEmpty()) link.enableLinkAudio(false)
    }

fun channels(): List<LinkAudioChannel> = if (closed) emptyList() else link.channels()

fun stereoInput(channelName: String? = null): StereoSignal

    fun stereoInput(channelName: String? = null): StereoSignal {
        val state = inputState(channelName)
        return StereoSignal(LinkCaptureSignal(state, 0), LinkCaptureSignal(state, 1))
    }

fun monoInput(channelName: String? = null): Signal

body — 41 lines, from line 261
    fun monoInput(channelName: String? = null): Signal {
        val state = inputState(channelName)
        return LinkCaptureSignal(state, -1)
    }

    private fun inputState(channelName: String?): LinkCaptureState {
        if (closed) return LinkCaptureState()
        link.enableLinkAudio(true)
        val channel = selectChannel(channelName) ?: return LinkCaptureState()
        val key = channel.id().toString()
        val state = inputStates.computeIfAbsent(key) { LinkCaptureState() }
        inputSources.computeIfAbsent(key) {
            link.createAudioSource(channel.id(), LinkAudioSource.BufferListener { buffer ->
                val info = buffer.info()
                state.update(buffer.copySamples(), info.numFrames().toInt(), info.numChannels().toInt())
            })
        }
        return state
    }

    private fun selectChannel(channelName: String?): LinkAudioChannel? {
        val all = channels()
        if (channelName.isNullOrBlank()) return all.firstOrNull()
        return all.firstOrNull { it.name().equals(channelName, ignoreCase = true) }
    }

    private fun snapshotFrom(
        state: SessionState,
        quantum: Double,
        now: Long = link.clockMicros()
    ): LinkTimingSnapshot = LinkTimingSnapshot(
        enabled = link.isEnabled,
        audioEnabled = link.isLinkAudioEnabled,
        peers = link.numPeers(),
        tempo = state.tempo(),
        beat = state.beatAtTime(now, quantum),
        phase = state.phaseAtTime(now, quantum),
        quantum = quantum,
        clockMicros = now,
        playing = state.isPlaying
    )

override fun liveGraphChildren(): Iterable<LiveGraphEdge>

    override fun liveGraphChildren(): Iterable<LiveGraphEdge> = listOf(
        LiveGraphEdge("enabled") { enabled },
        LiveGraphEdge("audioEnabled") { audioEnabled },
        LiveGraphEdge("peers") { peers },
        LiveGraphEdge("quantum") { quantum },
        LiveGraphEdge("sinkChannels") { sinkChannels },
        LiveGraphEdge("linkAudioCommits") { linkAudioCommits },
        LiveGraphEdge("linkAudioCommitFailures") { linkAudioCommitFailures },
        LiveGraphEdge("linkAudioInvalidBuffers") { linkAudioInvalidBuffers },
        LiveGraphEdge("channels") { channels() }
    )

override fun toString(): String

    override fun toString(): String = synthPrettyObject(
        this,
        "snapshot" to snapshot(),
        "linkAudioCommits" to linkAudioCommits,
        "linkAudioCommitFailures" to linkAudioCommitFailures,
        "linkAudioInvalidBuffers" to linkAudioInvalidBuffers,
        "channels" to channels().map { it.name() }
    )

override fun close()

    override fun close() {
        if (closed) return
        closed = true
        inputSources.values.forEach { runCatching { it.close() } }
        inputSources.clear()
        inputStates.clear()
        runCatching { sink?.close() }
        sink = null
        runCatching { audioState.close() }
        runCatching { appState.close() }
        runCatching { link.close() }
    }
}

internal data class LinkAudioBlock

body — 60 lines, from line 338
internal data class LinkAudioBlock(
    val state: SessionState,
    val snapshot: LinkTimingSnapshot,
    val beginBeat: Double
)

private class LinkCaptureState {
    private val lock = Any()
    private var left = FloatArray(0)
    private var right = FloatArray(0)
    private var frames = 0

    fun update(samples: ShortArray, frameCount: Int, channels: Int) {
        synchronized(lock) {
            if (left.size < frameCount) {
                left = FloatArray(frameCount)
                right = FloatArray(frameCount)
            }
            frames = frameCount
            for (i in 0 until frameCount) {
                val base = i * channels
                val l = if (base < samples.size) samples[base].toFloat() / Short.MAX_VALUE else 0f
                val r = if (channels > 1 && base + 1 < samples.size) {
                    samples[base + 1].toFloat() / Short.MAX_VALUE
                } else {
                    l
                }
                left[i] = l
                right[i] = r
            }
        }
    }

    fun copy(channel: Int, output: FloatArray, numFrames: Int) {
        synchronized(lock) {
            val count = minOf(numFrames, frames, left.size)
            when (channel) {
                0 -> System.arraycopy(left, 0, output, 0, count)
                1 -> System.arraycopy(right, 0, output, 0, count)
                else -> {
                    for (i in 0 until count) {
                        output[i] = (left[i] + right[i]) * 0.5f
                    }
                }
            }
            for (i in count until numFrames) {
                output[i] = 0f
            }
        }
    }
}

private class LinkCaptureSignal(
    private val state: LinkCaptureState,
    private val channel: Int
) : Signal() {
    override fun generate(output: FloatArray, numFrames: Int, ctx: SynthContext) {
        state.copy(channel, output, numFrames)
    }
}