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)
}
}