Skip to content

AbletonSetDebugDrawing.kt

src/jvmMain/kotlin/afield/audio/synth/AbletonSetDebugDrawing.kt · 356 lines

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

import afield.geometry.path.FLine
import afield.math.linalg.Vec4
import afield.graphics.render.color
import afield.graphics.render.filled
import afield.graphics.render.fline
import afield.graphics.color.hsl
import afield.graphics.render.rect
import afield.graphics.render.stroked
import afield.graphics.render.thickness
import afield.math.linalg.vec
import kotlin.math.max
import kotlin.math.min

An AbletonSet drawn as a timeline: a row of clip rectangles per track, an automation curve under it per envelope, at a fixed number of pixels per beat.

AbletonSet.toDebugFLines is the call and returns a flat list of FLines; debugFLineMap is the same drawing under one key, for a box that publishes its lines as a map. Both take an AbletonSetFLineDebugOptions or a builder block, and abletonSetFLineDebugOptions builds one on its own.

Geometry is beats times pixelsPerBeat along x and a stack of fixed-height lanes along y: a track takes clipLaneHeight plus one automationLaneHeight per envelope it carries, with trackGap between tracks. The drawing is at least sixteen beats wide however short the set is, and includeMainTrack decides whether the set's main track is drawn beside its ordinary ones. An envelope is sampled at automationSamples points across that width, and within its lane a higher value sits at a smaller y, normalised by the envelope's declared range, falling back to the spread of its own events, then to a unit interval around its manual value, then to 0 to 1. includeGuides rules a line under each track's clip row and another under the track; markAutomationEvents ticks each envelope event at its own time.

Colour comes from Live's track colour index into a palette of 32, with a golden-ratio hue as the fallback for a track that has no index or a negative one, and a disabled clip is drawn at a lower alpha rather than omitted. Every line carries ableton.* properties naming the track, clip, envelope or event it was drawn from, so the picture can be queried as well as looked at.

There is no connection to a running Live here. The input is an AbletonSet parsed off disk, so the drawing works on a machine that has never had Live installed.

data class AbletonSetFLineDebugOptions

data class AbletonSetFLineDebugOptions(
    val pixelsPerBeat: Double = 0.5,
    val clipLaneHeight: Double = 18.0,
    val automationLaneHeight: Double = 14.0,
    val trackGap: Double = 8.0,
    val clipVerticalPadding: Double = 2.0,
    val automationSamples: Int = 96,
    val includeMainTrack: Boolean = true,
    val includeGuides: Boolean = true,
    val markAutomationEvents: Boolean = true
)

class AbletonSetFLineDebugOptionsBuilder

var pixelsPerBeat: Double = 0.5

var clipLaneHeight: Double = 18.0

var automationLaneHeight: Double = 14.0

var trackGap: Double = 8.0

var clipVerticalPadding: Double = 2.0

var automationSamples: Int = 96

var includeMainTrack: Boolean = true

var includeGuides: Boolean = true

var markAutomationEvents: Boolean = true

fun build(): AbletonSetFLineDebugOptions

    fun build(): AbletonSetFLineDebugOptions =
        AbletonSetFLineDebugOptions(
            pixelsPerBeat = pixelsPerBeat,
            clipLaneHeight = clipLaneHeight,
            automationLaneHeight = automationLaneHeight,
            trackGap = trackGap,
            clipVerticalPadding = clipVerticalPadding,
            automationSamples = automationSamples,
            includeMainTrack = includeMainTrack,
            includeGuides = includeGuides,
            markAutomationEvents = markAutomationEvents
        )
}

fun abletonSetFLineDebugOptions

fun abletonSetFLineDebugOptions(
    block: AbletonSetFLineDebugOptionsBuilder.() -> Unit
): AbletonSetFLineDebugOptions =
    AbletonSetFLineDebugOptionsBuilder().apply(block).build()

fun AbletonSet.toDebugFLines

body — 74 lines, from line 87
fun AbletonSet.toDebugFLines(
    options: AbletonSetFLineDebugOptions = AbletonSetFLineDebugOptions()
): List<FLine> {
    val tracksToDraw = if (options.includeMainTrack) allTracks else tracks
    if (tracksToDraw.isEmpty()) return emptyList()

    val endBeat = max(visibleEndBeat(tracksToDraw), 16.0)
    val width = endBeat * options.pixelsPerBeat
    val lines = mutableListOf<FLine>()
    var y = 0.0

    tracksToDraw.forEachIndexed { visibleIndex, track ->
        val automationLanes = track.automation.size
        val trackHeight = options.clipLaneHeight + automationLanes * options.automationLaneHeight
        val trackColor = abletonTrackColor(track.color, visibleIndex, alpha = 0.82f)

        if (options.includeGuides) {
            lines += guideLine(0.0, y + options.clipLaneHeight, width, y + options.clipLaneHeight).tag(
                kind = "track-guide",
                track = track
            )
            lines += guideLine(0.0, y + trackHeight, width, y + trackHeight, alpha = 0.16f).tag(
                kind = "track-bottom",
                track = track
            )
        }

        track.clips.forEach { clip ->
            val x0 = clip.currentStartBeat * options.pixelsPerBeat
            val x1 = clip.currentEndBeat * options.pixelsPerBeat
            val clipTop = y + options.clipVerticalPadding
            val clipHeight = (options.clipLaneHeight - options.clipVerticalPadding * 2.0).coerceAtLeast(1.0)
            lines += rectLine(
                x = x0,
                y = clipTop,
                w = (x1 - x0).coerceAtLeast(0.5),
                h = clipHeight,
                color = withAlpha(trackColor, if (clip.disabled) 0.16f else 0.34f),
                filled = true
            ).tag(kind = "clip", track = track, clip = clip)
        }

        track.automation.forEachIndexed { automationIndex, envelope ->
            val laneTop = y + options.clipLaneHeight + automationIndex * options.automationLaneHeight
            val laneBottom = laneTop + options.automationLaneHeight
            val automationColor = automationColor(trackColor, automationIndex)
            lines += automationLine(
                envelope = envelope,
                startBeat = 0.0,
                endBeat = endBeat,
                laneTop = laneTop + 1.0,
                laneBottom = laneBottom - 1.0,
                xScale = options.pixelsPerBeat,
                samples = options.automationSamples,
                color = automationColor
            ).tag(kind = "automation", track = track, automation = envelope)

            if (options.markAutomationEvents) {
                val eventMarks = envelope.events
                    .filter { it.time in 0.0..endBeat }
                    .map { event ->
                        val x = event.time * options.pixelsPerBeat
                        tickLine(x, laneTop + 2.0, x, laneBottom - 2.0, withAlpha(automationColor, 0.72f))
                            .tag(kind = "automation-event", track = track, automation = envelope, event = event)
                    }
                lines += eventMarks
            }
        }

        y += trackHeight + options.trackGap
    }

    return lines
}

fun AbletonSet.toDebugFLines

fun AbletonSet.toDebugFLines(
    block: AbletonSetFLineDebugOptionsBuilder.() -> Unit
): List<FLine> =
    toDebugFLines(abletonSetFLineDebugOptions(block))

fun AbletonSet.debugFLineMap

fun AbletonSet.debugFLineMap(
    options: AbletonSetFLineDebugOptions = AbletonSetFLineDebugOptions()
): Map<String, List<FLine>> =
    mapOf("ableton-debug" to toDebugFLines(options))

fun AbletonSet.debugFLineMap

body — 184 lines, from line 172
fun AbletonSet.debugFLineMap(
    block: AbletonSetFLineDebugOptionsBuilder.() -> Unit
): Map<String, List<FLine>> =
    debugFLineMap(abletonSetFLineDebugOptions(block))

private fun visibleEndBeat(tracks: List<AbletonTrack>): Double {
    val clipEnd = tracks.flatMap { it.clips }.maxOfOrNull { it.currentEndBeat } ?: 0.0
    val automationEnd = tracks
        .flatMap { it.automation }
        .flatMap { it.events }
        .map { it.time }
        .filter { it >= 0.0 }
        .maxOrNull()
        ?: 0.0
    return max(clipEnd, automationEnd)
}

private fun automationLine(
    envelope: AbletonAutomationEnvelope,
    startBeat: Double,
    endBeat: Double,
    laneTop: Double,
    laneBottom: Double,
    xScale: Double,
    samples: Int,
    color: Vec4
): FLine {
    val valueRange = envelope.displayRange()
    val n = max(2, samples)
    return fline {
        this.color = color
        stroked = true
        filled = false
        thickness = 1.25
        for (i in 0 until n) {
            val t = if (n == 1) 0.0 else i.toDouble() / (n - 1).toDouble()
            val beat = startBeat + (endBeat - startBeat) * t
            val value = envelope.valueAt(beat).asDoubleOrNull()
            val normalized = value?.let { valueRange.normalize(it) } ?: 0.0
            val x = beat * xScale
            val y = laneBottom - normalized * (laneBottom - laneTop)
            if (i == 0) moveTo(x, y) else lineTo(x, y)
        }
    }
}

private fun AbletonAutomationEnvelope.displayRange(): ClosedFloatingPointRange<Double> {
    target.range?.let { range ->
        if (range.endInclusive > range.start) return range
    }
    val values = events.mapNotNull { it.value.asDoubleOrNull() }
    val minValue = values.minOrNull()
    val maxValue = values.maxOrNull()
    if (minValue != null && maxValue != null && maxValue > minValue) return minValue..maxValue
    val manual = target.manual?.asDoubleOrNull()
    return if (manual != null) {
        (manual - 1.0)..(manual + 1.0)
    } else {
        0.0..1.0
    }
}

private fun ClosedFloatingPointRange<Double>.normalize(value: Double): Double {
    val width = endInclusive - start
    if (width <= 0.0 || !width.isFinite()) return 0.0
    return ((value - start) / width).coerceIn(0.0, 1.0)
}

private fun rectLine(
    x: Double,
    y: Double,
    w: Double,
    h: Double,
    color: Vec4,
    filled: Boolean
): FLine =
    fline {
        rect(x, y, w, h)
        this.color = color
        this.filled = filled
        this.stroked = true
        thickness = 1.0
    }

private fun guideLine(x0: Double, y0: Double, x1: Double, y1: Double, alpha: Float = 0.24f): FLine =
    tickLine(x0, y0, x1, y1, vec(0.72, 0.74, 0.78, alpha))

private fun tickLine(x0: Double, y0: Double, x1: Double, y1: Double, color: Vec4): FLine =
    fline {
        moveTo(x0, y0)
        lineTo(x1, y1)
        this.color = color
        stroked = true
        filled = false
        thickness = 1.0
    }

private fun FLine.tag(
    kind: String,
    track: AbletonTrack? = null,
    clip: AbletonClip? = null,
    automation: AbletonAutomationEnvelope? = null,
    event: AbletonAutomationEvent? = null
): FLine {
    properties["ableton.kind"] = kind
    track?.let {
        properties["ableton.track.index"] = it.index
        properties["ableton.track.id"] = it.id
        properties["ableton.track.name"] = it.name
        properties["ableton.track.color"] = it.color
    }
    clip?.let {
        properties["ableton.clip.id"] = it.id
        properties["ableton.clip.name"] = it.name
        properties["ableton.clip.startBeat"] = it.currentStartBeat
        properties["ableton.clip.endBeat"] = it.currentEndBeat
    }
    automation?.let {
        properties["ableton.automation.id"] = it.id
        properties["ableton.automation.target"] = it.target.name
        properties["ableton.automation.events"] = it.events.size
    }
    event?.let {
        properties["ableton.automation.event.id"] = it.id
        properties["ableton.automation.event.time"] = it.time
        properties["ableton.automation.event.value"] = it.value.toString()
    }
    return this
}

private fun abletonTrackColor(colorIndex: Int?, fallbackIndex: Int, alpha: Float): Vec4 {
    val palette = abletonPalette
    val color = colorIndex?.takeIf { it >= 0 }?.let { palette.getOrNull(it % palette.size) }
        ?: hsl(((fallbackIndex * 0.61803398875) % 1.0), 0.54, 0.62, alpha)
    return withAlpha(color, alpha)
}

private fun automationColor(trackColor: Vec4, automationIndex: Int): Vec4 {
    val lift = min(0.95f, 0.62f + automationIndex * 0.08f)
    return vec(
        min(1.0f, trackColor.x * 0.65f + lift * 0.35f),
        min(1.0f, trackColor.y * 0.65f + lift * 0.35f),
        min(1.0f, trackColor.z * 0.65f + lift * 0.35f),
        0.95f
    )
}

private fun withAlpha(color: Vec4, alpha: Float): Vec4 =
    vec(color.x, color.y, color.z, alpha)

private val abletonPalette: List<Vec4> = listOf(
    vec(0.82, 0.22, 0.20, 1.0),
    vec(0.92, 0.38, 0.20, 1.0),
    vec(0.95, 0.56, 0.22, 1.0),
    vec(0.91, 0.72, 0.25, 1.0),
    vec(0.72, 0.78, 0.28, 1.0),
    vec(0.43, 0.74, 0.32, 1.0),
    vec(0.26, 0.68, 0.45, 1.0),
    vec(0.22, 0.66, 0.61, 1.0),
    vec(0.20, 0.59, 0.78, 1.0),
    vec(0.24, 0.45, 0.86, 1.0),
    vec(0.38, 0.35, 0.88, 1.0),
    vec(0.58, 0.33, 0.82, 1.0),
    vec(0.76, 0.30, 0.67, 1.0),
    vec(0.86, 0.28, 0.48, 1.0),
    vec(0.66, 0.66, 0.66, 1.0),
    vec(0.43, 0.43, 0.43, 1.0),
    vec(0.92, 0.78, 0.66, 1.0),
    vec(0.76, 0.57, 0.42, 1.0),
    vec(0.58, 0.43, 0.32, 1.0),
    vec(0.38, 0.29, 0.24, 1.0),
    vec(0.92, 0.66, 0.74, 1.0),
    vec(0.78, 0.49, 0.65, 1.0),
    vec(0.56, 0.40, 0.59, 1.0),
    vec(0.36, 0.31, 0.43, 1.0),
    vec(0.69, 0.84, 0.96, 1.0),
    vec(0.48, 0.67, 0.84, 1.0),
    vec(0.34, 0.50, 0.68, 1.0),
    vec(0.25, 0.34, 0.50, 1.0),
    vec(0.67, 0.86, 0.76, 1.0),
    vec(0.43, 0.68, 0.56, 1.0),
    vec(0.31, 0.51, 0.43, 1.0),
    vec(0.22, 0.36, 0.32, 1.0)
)