Skip to content

AbletonSet.kt

src/jvmMain/kotlin/afield/audio/synth/AbletonSet.kt · 1589 lines

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

import afield.audio.engine.WavFloatArrayLoader
import org.w3c.dom.Document
import org.w3c.dom.Element
import org.xml.sax.InputSource
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap
import javax.sound.midi.MetaMessage
import javax.sound.midi.MidiEvent
import javax.sound.midi.MidiSystem
import javax.sound.midi.ShortMessage
import javax.xml.XMLConstants
import javax.xml.parsers.DocumentBuilderFactory
import kotlin.math.PI
import kotlin.math.cos
import java.util.zip.GZIPInputStream
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt

An Ableton Live set read into Field: tracks, clips, tempo, automation and sample analysis.

AbletonSet.read parses an .als, which is gzipped XML — recognised by its magic bytes rather than its extension, so an already-unzipped set reads too. The model runs AbletonSet to AbletonTrack to AbletonClip, with AbletonTempoMap converting between arrangement beats and seconds.

The queries take an arrangement position and answer for that instant: AbletonSet.clipsPlayingAtBeat, AbletonSet.trackVolumesAtBeat and AbletonSet.dominantPitchesAtBeat, each with an AtSeconds twin. AbletonAutomationEnvelope carries the envelopes they read.

A clip names its audio through AbletonSampleRef, which AbletonSampleFileMapper and AbletonSet.resolveSamples resolve to files on this machine. Opening those files is what AbletonSampleVolumeAnalysisCache and AbletonSamplePitchAnalysisCache are for: both memoise in process and on disk, keyed on the sample's path, size, modification time and the analysis config, so a second run is a lookup rather than a re-read.

data class AbletonSet

One Ableton Live set, as read from an .als file by read.

tracks holds the set's own tracks and allTracks adds mainTrack when the set has one. clips and automationEnvelopes flatten the tree, for the common case of wanting every one of them without walking the tracks.

data class AbletonSet(
    val source: File,
    val tempoMap: AbletonTempoMap,
    val tracks: List<AbletonTrack>,
    val mainTrack: AbletonTrack? = null
) {

val clips: List<AbletonClip> = tracks.flatMap { it.clips }

val allTracks: List<AbletonTrack> = tracks + listOfNotNull(mainTrack)

val automationEnvelopes: List<AbletonAutomationEnvelope> = allTracks.flatMap { it.automation }

fun clipsPlayingAtBeat(arrangementBeat: Double): List<AbletonClipPlayback>

    fun clipsPlayingAtBeat(arrangementBeat: Double): List<AbletonClipPlayback> =
        clipsPlayingAtBeatInternal(arrangementBeat, sampleMapper = null)

fun clipsPlayingAtBeat

    fun clipsPlayingAtBeat(
        arrangementBeat: Double,
        sampleMapper: AbletonSampleFileMapper
    ): List<AbletonClipPlayback> =
        clipsPlayingAtBeatInternal(arrangementBeat, sampleMapper)

    private fun clipsPlayingAtBeatInternal(
        arrangementBeat: Double,
        sampleMapper: AbletonSampleFileMapper?
    ): List<AbletonClipPlayback> =
        tracks.flatMap { track ->
            track.clips
                .filter { it.containsBeat(arrangementBeat) }
                .map { clip ->
                    AbletonClipPlayback(
                        track = track,
                        clip = clip,
                        arrangementBeat = arrangementBeat,
                        arrangementSeconds = tempoMap.beatToSeconds(arrangementBeat),
                        sourceSeconds = clip.sourceSecondsAtBeat(arrangementBeat, tempoMap),
                        sourceFile = sampleMapper?.resolve(clip)
                    )
                }
        }

fun clipsPlayingAtSeconds(arrangementSeconds: Double): List<AbletonClipPlayback>

    fun clipsPlayingAtSeconds(arrangementSeconds: Double): List<AbletonClipPlayback> =
        clipsPlayingAtBeat(tempoMap.secondsToBeat(arrangementSeconds))

fun clipsPlayingAtSeconds

    fun clipsPlayingAtSeconds(
        arrangementSeconds: Double,
        sampleMapper: AbletonSampleFileMapper
    ): List<AbletonClipPlayback> =
        clipsPlayingAtBeat(tempoMap.secondsToBeat(arrangementSeconds), sampleMapper)

fun trackVolumesAtBeat

    fun trackVolumesAtBeat(
        arrangementBeat: Double,
        sampleMapper: AbletonSampleFileMapper,
        analysisCache: AbletonSampleVolumeAnalysisCache = AbletonSampleVolumeAnalysisCache.shared
    ): List<AbletonTrackVolume> {
        val byTrack = clipsPlayingAtBeat(arrangementBeat, sampleMapper)
            .mapNotNull { playback -> playback.volumeContribution(analysisCache) }
            .groupBy { it.track }

        return byTrack.map { (track, contributions) ->
            AbletonTrackVolume(
                track = track,
                arrangementBeat = arrangementBeat,
                arrangementSeconds = tempoMap.beatToSeconds(arrangementBeat),
                bands = contributions.rootSumSquareBands(),
                clips = contributions
            )
        }.sortedBy { it.track.index }
    }

fun trackVolumesAtSeconds

    fun trackVolumesAtSeconds(
        arrangementSeconds: Double,
        sampleMapper: AbletonSampleFileMapper,
        analysisCache: AbletonSampleVolumeAnalysisCache = AbletonSampleVolumeAnalysisCache.shared
    ): List<AbletonTrackVolume> =
        trackVolumesAtBeat(tempoMap.secondsToBeat(arrangementSeconds), sampleMapper, analysisCache)

fun dominantPitchesAtBeat

    fun dominantPitchesAtBeat(
        arrangementBeat: Double,
        sampleMapper: AbletonSampleFileMapper,
        pitchCache: AbletonSamplePitchAnalysisCache = AbletonSamplePitchAnalysisCache.shared
    ): List<AbletonClipPitch> =
        clipsPlayingAtBeat(arrangementBeat, sampleMapper)
            .mapNotNull { playback -> playback.pitchContribution(pitchCache) }
            .sortedWith(compareBy<AbletonClipPitch> { it.track.index }.thenBy { it.clip.currentStartBeat })

fun dominantPitchesAtSeconds

    fun dominantPitchesAtSeconds(
        arrangementSeconds: Double,
        sampleMapper: AbletonSampleFileMapper,
        pitchCache: AbletonSamplePitchAnalysisCache = AbletonSamplePitchAnalysisCache.shared
    ): List<AbletonClipPitch> =
        dominantPitchesAtBeat(tempoMap.secondsToBeat(arrangementSeconds), sampleMapper, pitchCache)

fun dominantPitchesByTrackAtBeat

    fun dominantPitchesByTrackAtBeat(
        arrangementBeat: Double,
        sampleMapper: AbletonSampleFileMapper,
        pitchCache: AbletonSamplePitchAnalysisCache = AbletonSamplePitchAnalysisCache.shared
    ): List<AbletonTrackPitch> {
        val clipPitches = dominantPitchesAtBeat(arrangementBeat, sampleMapper, pitchCache)
        return clipPitches
            .groupBy { it.track }
            .mapNotNull { (track, contributions) ->
                val dominant = contributions.dominantClipPitchOrNull() ?: return@mapNotNull null
                AbletonTrackPitch(
                    track = track,
                    arrangementBeat = arrangementBeat,
                    arrangementSeconds = tempoMap.beatToSeconds(arrangementBeat),
                    pitch = dominant.pitch,
                    clips = contributions
                )
            }
            .sortedBy { it.track.index }
    }

fun dominantPitchesByTrackAtSeconds

    fun dominantPitchesByTrackAtSeconds(
        arrangementSeconds: Double,
        sampleMapper: AbletonSampleFileMapper,
        pitchCache: AbletonSamplePitchAnalysisCache = AbletonSamplePitchAnalysisCache.shared
    ): List<AbletonTrackPitch> =
        dominantPitchesByTrackAtBeat(tempoMap.secondsToBeat(arrangementSeconds), sampleMapper, pitchCache)

fun track(name: String): AbletonTrack? = tracks.firstOrNull { it.name == name }

fun resolveSamples(searchDirectory: File): Map<AbletonClip, File>

    fun resolveSamples(searchDirectory: File): Map<AbletonClip, File> {
        val mapper = AbletonSampleFileMapper.forDirectory(searchDirectory)
        return clips.mapNotNull { clip -> mapper.resolve(clip)?.let { clip to it } }.toMap()
    }

companion object

fun read(path: String): AbletonSet = read(File(path))

fun read(file: File): AbletonSet

body — 45 lines, from line 185
        fun read(file: File): AbletonSet {
            val document = parseXml(file)
            val liveSet = document.documentElement.childElements("LiveSet").firstOrNull()
                ?: error("No <LiveSet> element found in ${file.absolutePath}")
            val masterTrack = liveSet.firstDescendant("MasterTrack")
            val tempoMap = AbletonTempoMap.fromMasterTrack(masterTrack)
            val tracks = liveSet
                .childElements("Tracks")
                .firstOrNull()
                ?.childElements()
                ?.filter { it.tagName in setOf("AudioTrack", "MidiTrack", "GroupTrack", "ReturnTrack") }
                ?.mapIndexed { index, element -> AbletonTrack.fromElement(index, element) }
                ?: emptyList()
            val mainTrack = liveSet.childElements("MainTrack")
                .firstOrNull()
                ?.let { AbletonTrack.fromElement(tracks.size, it) }
            return AbletonSet(file, tempoMap, tracks, mainTrack)
        }

        private fun parseXml(file: File): Document {
            val factory = DocumentBuilderFactory.newInstance()
            factory.isNamespaceAware = false
            factory.isIgnoringComments = true
            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true)
            factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
            return file.inputStream().buffered().use { raw ->
                val input = if (file.looksGzipped()) GZIPInputStream(raw) else raw
                input.use {
                    factory.newDocumentBuilder().parse(InputSource(it)).also { document ->
                        document.documentElement.normalize()
                    }
                }
            }
        }

        private fun File.looksGzipped(): Boolean {
            inputStream().use { stream ->
                val first = stream.read()
                val second = stream.read()
                return first == (GZIPInputStream.GZIP_MAGIC and 0xff) &&
                    second == ((GZIPInputStream.GZIP_MAGIC shr 8) and 0xff)
            }
        }
    }
}

data class AbletonTrack

data class AbletonTrack(
    val index: Int,
    val id: String,
    val kind: String,
    val name: String,
    val color: Int?,
    val clips: List<AbletonClip>,
    val automation: List<AbletonAutomationEnvelope>
) {

fun clipsPlayingAtBeat(arrangementBeat: Double): List<AbletonClip>

    fun clipsPlayingAtBeat(arrangementBeat: Double): List<AbletonClip> =
        clips.filter { it.containsBeat(arrangementBeat) }

fun automationValuesAt(arrangementTime: Double): Map<AbletonAutomationTarget, AbletonAutomationValue>

    fun automationValuesAt(arrangementTime: Double): Map<AbletonAutomationTarget, AbletonAutomationValue> =
        automation.associate { it.target to it.valueAt(arrangementTime) }

companion object

internal fun fromElement(index: Int, track: Element): AbletonTrack

        internal fun fromElement(index: Int, track: Element): AbletonTrack {
            val nameElement = track.childElements("Name").firstOrNull()
            val name = nameElement?.valueChild("EffectiveName")
                ?: nameElement?.valueChild("UserName")
                ?: "${track.tagName} ${track.attr("Id") ?: index}"
            val targets = track.automationTargetsById()
            val clips = track.descendants()
                .filter { it.tagName == "AudioClip" || it.tagName == "MidiClip" }
                .mapNotNull { AbletonClip.fromElement(it) }
                .sortedBy { it.currentStartBeat }
                .toList()
            val automation = track.childElements("AutomationEnvelopes")
                .firstOrNull()
                ?.firstDescendant("Envelopes")
                ?.childElements("AutomationEnvelope")
                ?.mapNotNull { AbletonAutomationEnvelope.fromElement(it, targets) }
                ?: emptyList()
            return AbletonTrack(
                index = index,
                id = track.attr("Id") ?: index.toString(),
                kind = track.tagName.removeSuffix("Track").ifEmpty { track.tagName }.lowercase(),
                name = name,
                color = track.valueChild("Color")?.toIntOrNull(),
                clips = clips,
                automation = automation
            )
        }
    }
}

data class AbletonClip

data class AbletonClip(
    val id: String,
    val kind: AbletonClipKind,
    val name: String,
    val arrangementTimeBeat: Double,
    val currentStartBeat: Double,
    val currentEndBeat: Double,
    val loop: AbletonClipLoop,
    val disabled: Boolean,
    val sample: AbletonSampleRef?,
    val warpMarkers: List<AbletonWarpMarker>,
    val isWarped: Boolean?
) {

val durationBeats: Double get() = max(0.0, currentEndBeat - currentStartBeat)

fun containsBeat(arrangementBeat: Double): Boolean

    fun containsBeat(arrangementBeat: Double): Boolean =
        !disabled && arrangementBeat >= currentStartBeat && arrangementBeat < currentEndBeat

fun sourceSecondsAtBeat(arrangementBeat: Double, tempoMap: AbletonTempoMap): Double

    fun sourceSecondsAtBeat(arrangementBeat: Double, tempoMap: AbletonTempoMap): Double {
        val localBeat = localBeatAt(arrangementBeat)
        if (warpMarkers.size >= 2) {
            return warpMarkers.sourceSecondsAtClipBeat(localBeat)
        }

        val localSeconds = tempoMap.beatsToDurationSeconds(localBeat)
        return loop.loopStartSeconds + localSeconds
    }

fun localBeatAt(arrangementBeat: Double): Double

    fun localBeatAt(arrangementBeat: Double): Double {
        val rawBeat = arrangementBeat - currentStartBeat + loop.startRelativeBeat
        if (!loop.loopOn || loop.loopEndSeconds <= loop.loopStartSeconds) {
            return rawBeat
        }

        val markerStartBeat = warpMarkers.clipBeatAtSourceSecondsOrNull(loop.loopStartSeconds)
        val markerEndBeat = warpMarkers.clipBeatAtSourceSecondsOrNull(loop.loopEndSeconds)
        val loopStartBeat = markerStartBeat ?: 0.0
        val loopLengthBeat = (markerEndBeat ?: durationBeats) - loopStartBeat
        if (loopLengthBeat <= 0.0) return rawBeat
        return loopStartBeat + ((rawBeat - loopStartBeat) positiveMod loopLengthBeat)
    }

companion object

internal fun fromElement(clip: Element): AbletonClip?

        internal fun fromElement(clip: Element): AbletonClip? {
            val arrangementTime = clip.attr("Time")?.toDoubleOrNull() ?: return null
            val loop = AbletonClipLoop.fromElement(clip.childElements("Loop").firstOrNull())
            return AbletonClip(
                id = clip.attr("Id") ?: "",
                kind = if (clip.tagName == "MidiClip") AbletonClipKind.Midi else AbletonClipKind.Audio,
                name = clip.valueChild("Name") ?: "${clip.tagName} ${clip.attr("Id") ?: ""}".trim(),
                arrangementTimeBeat = arrangementTime,
                currentStartBeat = clip.valueChild("CurrentStart")?.toDoubleOrNull() ?: arrangementTime,
                currentEndBeat = clip.valueChild("CurrentEnd")?.toDoubleOrNull() ?: arrangementTime,
                loop = loop,
                disabled = clip.valueChild("Disabled")?.toBooleanStrictOrNull() ?: false,
                sample = clip.childElements("SampleRef").firstOrNull()?.let(AbletonSampleRef::fromElement),
                warpMarkers = clip.firstDescendant("WarpMarkers")
                    ?.childElements("WarpMarker")
                    ?.mapNotNull(AbletonWarpMarker::fromElement)
                    ?.sortedBy { it.clipBeat }
                    ?: emptyList(),
                isWarped = clip.valueChild("IsWarped")?.toBooleanStrictOrNull()
            )
        }
    }
}

enum class AbletonClipKind

enum class AbletonClipKind {
    Audio,
    Midi
}

data class AbletonClipLoop

data class AbletonClipLoop(
    val loopStartSeconds: Double,
    val loopEndSeconds: Double,
    val startRelativeBeat: Double,
    val loopOn: Boolean,
    val outMarkerSeconds: Double?,
    val hiddenLoopStartSeconds: Double?,
    val hiddenLoopEndSeconds: Double?
) {

companion object

internal fun fromElement(loop: Element?): AbletonClipLoop

        internal fun fromElement(loop: Element?): AbletonClipLoop =
            AbletonClipLoop(
                loopStartSeconds = loop?.valueChild("LoopStart")?.toDoubleOrNull() ?: 0.0,
                loopEndSeconds = loop?.valueChild("LoopEnd")?.toDoubleOrNull() ?: 0.0,
                startRelativeBeat = loop?.valueChild("StartRelative")?.toDoubleOrNull() ?: 0.0,
                loopOn = loop?.valueChild("LoopOn")?.toBooleanStrictOrNull() ?: false,
                outMarkerSeconds = loop?.valueChild("OutMarker")?.toDoubleOrNull(),
                hiddenLoopStartSeconds = loop?.valueChild("HiddenLoopStart")?.toDoubleOrNull(),
                hiddenLoopEndSeconds = loop?.valueChild("HiddenLoopEnd")?.toDoubleOrNull()
            )
    }
}

data class AbletonSampleRef

data class AbletonSampleRef(
    val relativePath: String?,
    val absolutePath: String?,
    val defaultDurationSamples: Long?,
    val defaultSampleRate: Double?,
    val originalFileSize: Long?,
    val originalCrc: Long?
) {

val defaultDurationSeconds: Double?

    val defaultDurationSeconds: Double?
        get() = if (defaultDurationSamples != null && defaultSampleRate != null && defaultSampleRate > 0.0) {
            defaultDurationSamples / defaultSampleRate
        } else {
            null
        }

companion object

internal fun fromElement(sampleRef: Element): AbletonSampleRef

        internal fun fromElement(sampleRef: Element): AbletonSampleRef {
            val fileRef = sampleRef.childElements("FileRef").firstOrNull()
            return AbletonSampleRef(
                relativePath = fileRef?.valueChild("RelativePath"),
                absolutePath = fileRef?.valueChild("Path"),
                defaultDurationSamples = sampleRef.valueChild("DefaultDuration")?.toLongOrNull(),
                defaultSampleRate = sampleRef.valueChild("DefaultSampleRate")?.toDoubleOrNull(),
                originalFileSize = fileRef?.valueChild("OriginalFileSize")?.toLongOrNull(),
                originalCrc = fileRef?.valueChild("OriginalCrc")?.toLongOrNull()
            )
        }
    }
}

class AbletonSampleFileMapper private constructor

class AbletonSampleFileMapper private constructor(
    val searchDirectory: File,
    private val filesByName: Map<String, List<File>>,
    private val filesBySuffix: Map<String, List<File>>
) {

fun resolve(clip: AbletonClip): File? = resolve(clip.sample)

fun resolve(playback: AbletonClipPlayback): File? = resolve(playback.clip)

fun resolve(sample: AbletonSampleRef?): File?

    fun resolve(sample: AbletonSampleRef?): File? {
        sample ?: return null
        sample.absolutePath?.let { path ->
            File(path).takeIf { it.isFile }?.let { return it }
        }
        sample.relativePath?.let { path ->
            val relativeToSearch = File(searchDirectory, path).takeIf { it.isFile }
            if (relativeToSearch != null) return relativeToSearch
        }

        val candidates = sample.lookupKeys()
        for (key in candidates) {
            filesBySuffix[key]?.singleOrNull()?.let { return it }
        }
        for (key in candidates) {
            filesBySuffix[key]?.firstOrNull()?.let { return it }
        }

        sample.fileNameKey()?.let { key ->
            filesByName[key]?.singleOrNull()?.let { return it }
            filesByName[key]?.firstOrNull()?.let { return it }
        }
        return null
    }

fun candidates(sample: AbletonSampleRef?): List<File>

    fun candidates(sample: AbletonSampleRef?): List<File> {
        sample ?: return emptyList()
        val suffixMatches = sample.lookupKeys().flatMap { filesBySuffix[it].orEmpty() }
        val nameMatches = sample.fileNameKey()?.let { filesByName[it].orEmpty() }.orEmpty()
        return (suffixMatches + nameMatches).distinctBy { it.absolutePath }
    }

companion object

    companion object {
        private val cache = ConcurrentHashMap<String, AbletonSampleFileMapper>()

fun forDirectory(searchDirectory: File): AbletonSampleFileMapper

        fun forDirectory(searchDirectory: File): AbletonSampleFileMapper {
            val root = searchDirectory.absoluteFile.normalize()
            val key = root.path
            return cache.getOrPut(key) { build(root) }
        }

fun clearCache()

        fun clearCache() {
            cache.clear()
        }

        private fun build(root: File): AbletonSampleFileMapper {
            val files = if (root.isDirectory) {
                root.walkTopDown().filter { it.isFile }.toList()
            } else {
                emptyList()
            }
            val byName = files.groupBy { it.name.lowercase() }
            val bySuffix = mutableMapOf<String, MutableList<File>>()
            for (file in files) {
                val relative = file.relativeToOrSelf(root).invariantPath()
                val segments = relative.split('/').filter { it.isNotBlank() }
                for (index in segments.indices) {
                    val suffix = segments.drop(index).joinToString("/")
                    bySuffix.getOrPut(suffix.lowercase()) { mutableListOf() }.add(file)
                }
            }
            return AbletonSampleFileMapper(
                searchDirectory = root,
                filesByName = byName,
                filesBySuffix = bySuffix
            )
        }
    }
}

data class AbletonWarpMarker

data class AbletonWarpMarker(
    val id: String,
    val sourceSeconds: Double,
    val clipBeat: Double
) {

companion object

internal fun fromElement(marker: Element): AbletonWarpMarker?

        internal fun fromElement(marker: Element): AbletonWarpMarker? {
            val sourceSeconds = marker.attr("SecTime")?.toDoubleOrNull() ?: return null
            val clipBeat = marker.attr("BeatTime")?.toDoubleOrNull() ?: return null
            return AbletonWarpMarker(
                id = marker.attr("Id") ?: "",
                sourceSeconds = sourceSeconds,
                clipBeat = clipBeat
            )
        }
    }
}

data class AbletonClipPlayback

data class AbletonClipPlayback(
    val track: AbletonTrack,
    val clip: AbletonClip,
    val arrangementBeat: Double,
    val arrangementSeconds: Double,
    val sourceSeconds: Double,
    val sourceFile: File? = null
) {

val sourcePath: String? get() = sourceFile?.absolutePath ?: originalSourcePath

val originalSourcePath: String? get() = clip.sample?.relativePath ?: clip.sample?.absolutePath

data class AbletonTrackVolume

data class AbletonTrackVolume(
    val track: AbletonTrack,
    val arrangementBeat: Double,
    val arrangementSeconds: Double,
    val bands: AbletonVolumeBands,
    val clips: List<AbletonClipVolume>
) {

val rms: Double get() = bands.rms

data class AbletonClipVolume

data class AbletonClipVolume(
    val track: AbletonTrack,
    val clip: AbletonClip,
    val sourceFile: File,
    val sourceSeconds: Double,
    val bands: AbletonVolumeBands
) {

val rms: Double get() = bands.rms

data class AbletonVolumeBands

data class AbletonVolumeBands(
    val low: Double,
    val mid: Double,
    val high: Double
) {

val rms: Double get() = sqrt((low * low + mid * mid + high * high) / 3.0)

companion object

val Zero = AbletonVolumeBands(0.0, 0.0, 0.0)

data class AbletonSampleVolumeAnalysisConfig

data class AbletonSampleVolumeAnalysisConfig(
    val windowSeconds: Double = 0.150,
    val hopSeconds: Double = 0.005,
    val lowCutoffHz: Double = 250.0,
    val highCutoffHz: Double = 2_000.0,
    val fftSize: Int = 2048
) {

init

    init {
        require(windowSeconds > 0.0) { "windowSeconds must be > 0" }
        require(hopSeconds > 0.0) { "hopSeconds must be > 0" }
        require(lowCutoffHz > 0.0) { "lowCutoffHz must be > 0" }
        require(highCutoffHz > lowCutoffHz) { "highCutoffHz must be > lowCutoffHz" }
        require(fftSize > 0 && fftSize and (fftSize - 1) == 0) { "fftSize must be a power of two" }
    }
}

data class AbletonSampleVolumeAnalysis

data class AbletonSampleVolumeAnalysis(
    val sourceFile: File,
    val sampleRate: Int,
    val sampleCount: Int,
    val config: AbletonSampleVolumeAnalysisConfig,
    val hopSeconds: Double,
    val frames: List<AbletonVolumeBands>
) {

val durationSeconds: Double get() = sampleCount.toDouble() / sampleRate.toDouble()

fun volumeAtSeconds(sourceSeconds: Double): AbletonVolumeBands

    fun volumeAtSeconds(sourceSeconds: Double): AbletonVolumeBands {
        if (frames.isEmpty() || sourceSeconds < 0.0 || sourceSeconds > durationSeconds) return AbletonVolumeBands.Zero
        val frame = sourceSeconds / hopSeconds
        val leftIndex = frame.toInt().coerceIn(0, frames.lastIndex)
        val rightIndex = min(leftIndex + 1, frames.lastIndex)
        val t = (frame - leftIndex).coerceIn(0.0, 1.0)
        return frames[leftIndex].lerp(frames[rightIndex], t)
    }

companion object

fun analyze

body — 153 lines, from line 579
        fun analyze(
            sourceFile: File,
            config: AbletonSampleVolumeAnalysisConfig = AbletonSampleVolumeAnalysisConfig()
        ): AbletonSampleVolumeAnalysis {
            val wav = WavFloatArrayLoader.loadWavToFloatArray(sourceFile.absolutePath)
            val hopSamples = max(1, (config.hopSeconds * wav.sampleRate).roundToInt())
            val frames = analyzeFrames(wav.samples, wav.sampleRate, config, hopSamples)
            return AbletonSampleVolumeAnalysis(
                sourceFile = sourceFile.absoluteFile.normalize(),
                sampleRate = wav.sampleRate,
                sampleCount = wav.samples.size,
                config = config,
                hopSeconds = hopSamples.toDouble() / wav.sampleRate.toDouble(),
                frames = frames
            )
        }

        private fun analyzeFrames(
            samples: FloatArray,
            sampleRate: Int,
            config: AbletonSampleVolumeAnalysisConfig,
            hopSamples: Int
        ): List<AbletonVolumeBands> {
            if (samples.isEmpty()) return emptyList()
            val fftSize = max(config.fftSize, nextPowerOfTwo((config.windowSeconds * sampleRate).toInt().coerceAtLeast(2)))
            val complex = DoubleArray(fftSize * 2)
            val windowSamples = min(samples.size, (config.windowSeconds * sampleRate).toInt().coerceAtLeast(1))
            val frameCount = max(1, ((samples.size - 1) / hopSamples) + 1)

            return List(frameCount) { frameIndex ->
                val center = frameIndex * hopSamples
                val start = center - windowSamples / 2
                java.util.Arrays.fill(complex, 0.0)
                for (i in 0 until windowSamples) {
                    val sampleIndex = start + i
                    if (sampleIndex !in samples.indices) continue
                    val window = hann(i, windowSamples)
                    complex[i * 2] = samples[sampleIndex].toDouble() * window
                }
                forwardFftDouble(complex, fftSize)
                spectralBands(complex, fftSize, sampleRate, config)
            }
        }

        private fun spectralBands(
            complex: DoubleArray,
            fftSize: Int,
            sampleRate: Int,
            config: AbletonSampleVolumeAnalysisConfig
        ): AbletonVolumeBands {
            var low = 0.0
            var mid = 0.0
            var high = 0.0
            var lowCount = 0
            var midCount = 0
            var highCount = 0

            for (bin in 1..fftSize / 2) {
                val frequency = bin.toDouble() * sampleRate.toDouble() / fftSize.toDouble()
                val re = complex[bin * 2]
                val im = complex[bin * 2 + 1]
                val power = re * re + im * im
                when {
                    frequency < config.lowCutoffHz -> {
                        low += power
                        lowCount++
                    }
                    frequency < config.highCutoffHz -> {
                        mid += power
                        midCount++
                    }
                    else -> {
                        high += power
                        highCount++
                    }
                }
            }

            val scale = 2.0 / fftSize.toDouble()
            return AbletonVolumeBands(
                low = bandRms(low, lowCount, scale),
                mid = bandRms(mid, midCount, scale),
                high = bandRms(high, highCount, scale)
            )
        }

        private fun bandRms(power: Double, count: Int, scale: Double): Double =
            if (count <= 0) 0.0 else sqrt(power / count.toDouble()) * scale

        private fun hann(index: Int, size: Int): Double =
            if (size <= 1) 1.0 else 0.5 - 0.5 * cos((2.0 * PI * index) / (size - 1))

        private fun forwardFftDouble(data: DoubleArray, size: Int) {
            var j = 0
            for (i in 0 until size) {
                if (j > i) {
                    val ai = i * 2
                    val aj = j * 2
                    val re = data[ai]
                    val im = data[ai + 1]
                    data[ai] = data[aj]
                    data[ai + 1] = data[aj + 1]
                    data[aj] = re
                    data[aj + 1] = im
                }
                var bit = size shr 1
                while (bit >= 1 && j >= bit) {
                    j -= bit
                    bit = bit shr 1
                }
                j += bit
            }

            var step = 2
            while (step <= size) {
                val halfStep = step / 2
                val theta = -2.0 * PI / step.toDouble()
                val wStepRe = cos(theta)
                val wStepIm = kotlin.math.sin(theta)
                for (group in 0 until size step step) {
                    var wRe = 1.0
                    var wIm = 0.0
                    for (pair in 0 until halfStep) {
                        val even = (group + pair) * 2
                        val odd = (group + pair + halfStep) * 2
                        val oddRe = data[odd]
                        val oddIm = data[odd + 1]
                        val twiddledRe = wRe * oddRe - wIm * oddIm
                        val twiddledIm = wRe * oddIm + wIm * oddRe
                        val evenRe = data[even]
                        val evenIm = data[even + 1]
                        data[even] = evenRe + twiddledRe
                        data[even + 1] = evenIm + twiddledIm
                        data[odd] = evenRe - twiddledRe
                        data[odd + 1] = evenIm - twiddledIm

                        val nextWRe = wRe * wStepRe - wIm * wStepIm
                        val nextWIm = wRe * wStepIm + wIm * wStepRe
                        wRe = nextWRe
                        wIm = nextWIm
                    }
                }
                step = step shl 1
            }
        }

        private fun nextPowerOfTwo(value: Int): Int {
            var out = 1
            while (out < value) out = out shl 1
            return out
        }
    }
}

class AbletonSampleVolumeAnalysisCache

class AbletonSampleVolumeAnalysisCache(
    private val config: AbletonSampleVolumeAnalysisConfig = AbletonSampleVolumeAnalysisConfig()
) {
    private val cache = ConcurrentHashMap<AbletonSampleAnalysisKey, AbletonSampleVolumeAnalysis>()

fun analysisFor(sourceFile: File): AbletonSampleVolumeAnalysis

    fun analysisFor(sourceFile: File): AbletonSampleVolumeAnalysis {
        val file = sourceFile.absoluteFile.normalize()
        val key = AbletonSampleAnalysisKey(
            path = file.absolutePath,
            lastModified = file.lastModified(),
            length = file.length(),
            config = config
        )
        cache[key]?.let { return it }

        return cache.computeIfAbsent(key) { loadFromDiskOrAnalyze(file, key) }
    }

fun clear()

    fun clear() {
        cache.clear()
    }

companion object

val shared = AbletonSampleVolumeAnalysisCache()

source — 151 lines, from line 759
    private fun loadFromDiskOrAnalyze(file: File, key: AbletonSampleAnalysisKey): AbletonSampleVolumeAnalysis {
        val cacheFile = diskCacheFileFor(file, config)
        readDiskCache(cacheFile, file, key)?.let { analysis ->
            println(
                "Ableton volume analysis: loaded cache ${cacheFile.name} " +
                    "for ${file.name} frames=${analysis.frames.size}"
            )
            return analysis
        }

        val startNs = System.nanoTime()
        println(
            "Ableton volume analysis: computing ${file.absolutePath} " +
                "(${formatByteCount(file.length())}, window=${config.windowSeconds}s, hop=${config.hopSeconds}s, fft=${config.fftSize})"
        )
        return AbletonSampleVolumeAnalysis.analyze(file, config).also { analysis ->
            writeDiskCache(cacheFile, file, key, analysis)
            val elapsedMs = (System.nanoTime() - startNs) / 1_000_000.0
            println(
                "Ableton volume analysis: cached ${file.name} " +
                    "frames=${analysis.frames.size}, duration=${"%.3f".format(analysis.durationSeconds)}s, " +
                    "elapsed=${"%.1f".format(elapsedMs)}ms, disk=${cacheFile.name}"
            )
        }
    }
}

private const val ABLETON_VOLUME_CACHE_MAGIC = "F4 Ableton Volume Analysis"
private const val ABLETON_VOLUME_CACHE_VERSION = 3

private fun diskCacheFileFor(file: File, config: AbletonSampleVolumeAnalysisConfig): File {
    val suffix = listOf(
        "w${config.windowSeconds.cacheToken()}",
        "h${config.hopSeconds.cacheToken()}",
        "l${config.lowCutoffHz.cacheToken()}",
        "u${config.highCutoffHz.cacheToken()}",
        "f${config.fftSize}"
    ).joinToString("-")
    return File(file.parentFile ?: File("."), "${file.name}.field4-volume-$suffix.bin")
}

private fun readDiskCache(
    cacheFile: File,
    sourceFile: File,
    key: AbletonSampleAnalysisKey
): AbletonSampleVolumeAnalysis? {
    if (!cacheFile.isFile || cacheFile.lastModified() < sourceFile.lastModified()) return null

    return runCatching {
        DataInputStream(BufferedInputStream(cacheFile.inputStream())).use { input ->
            if (input.readUTF() != ABLETON_VOLUME_CACHE_MAGIC) return null
            if (input.readInt() != ABLETON_VOLUME_CACHE_VERSION) return null
            if (input.readUTF() != key.path) return null
            if (input.readLong() != key.lastModified) return null
            if (input.readLong() != key.length) return null

            val storedConfig = AbletonSampleVolumeAnalysisConfig(
                windowSeconds = input.readDouble(),
                hopSeconds = input.readDouble(),
                lowCutoffHz = input.readDouble(),
                highCutoffHz = input.readDouble(),
                fftSize = input.readInt()
            )
            if (storedConfig != key.config) return null

            val sampleRate = input.readInt()
            val sampleCount = input.readInt()
            val actualHopSeconds = input.readDouble()
            val frameCount = input.readInt()
            if (sampleRate <= 0 || sampleCount < 0 || actualHopSeconds <= 0.0 || frameCount < 0) return null

            val frames = List(frameCount) {
                AbletonVolumeBands(
                    low = input.readDouble(),
                    mid = input.readDouble(),
                    high = input.readDouble()
                )
            }
            AbletonSampleVolumeAnalysis(
                sourceFile = sourceFile.absoluteFile.normalize(),
                sampleRate = sampleRate,
                sampleCount = sampleCount,
                config = key.config,
                hopSeconds = actualHopSeconds,
                frames = frames
            )
        }
    }.getOrNull()
}

private fun writeDiskCache(
    cacheFile: File,
    sourceFile: File,
    key: AbletonSampleAnalysisKey,
    analysis: AbletonSampleVolumeAnalysis
) {
    runCatching {
        cacheFile.parentFile?.mkdirs()
        val tmp = File(cacheFile.parentFile ?: File("."), "${cacheFile.name}.tmp-${System.nanoTime()}")
        DataOutputStream(BufferedOutputStream(tmp.outputStream())).use { output ->
            output.writeUTF(ABLETON_VOLUME_CACHE_MAGIC)
            output.writeInt(ABLETON_VOLUME_CACHE_VERSION)
            output.writeUTF(key.path)
            output.writeLong(key.lastModified)
            output.writeLong(key.length)
            output.writeDouble(key.config.windowSeconds)
            output.writeDouble(key.config.hopSeconds)
            output.writeDouble(key.config.lowCutoffHz)
            output.writeDouble(key.config.highCutoffHz)
            output.writeInt(key.config.fftSize)
            output.writeInt(analysis.sampleRate)
            output.writeInt(analysis.sampleCount)
            output.writeDouble(analysis.hopSeconds)
            output.writeInt(analysis.frames.size)
            for (frame in analysis.frames) {
                output.writeDouble(frame.low)
                output.writeDouble(frame.mid)
                output.writeDouble(frame.high)
            }
        }
        Files.move(tmp.toPath(), cacheFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
        cacheFile.setLastModified(sourceFile.lastModified().coerceAtLeast(System.currentTimeMillis()))
    }.onFailure { error ->
        println("Ableton volume analysis: could not write disk cache ${cacheFile.absolutePath}: ${error.message}")
    }
}

private fun Double.cacheToken(): String =
    toString().replace('-', 'm').replace('.', 'p')

private fun formatByteCount(bytes: Long): String {
    val units = arrayOf("B", "KB", "MB", "GB")
    var value = bytes.toDouble()
    var unit = 0
    while (value >= 1024.0 && unit < units.lastIndex) {
        value /= 1024.0
        unit++
    }
    return if (unit == 0) {
        "$bytes ${units[unit]}"
    } else {
        "%.1f %s".format(value, units[unit])
    }
}

private data class AbletonSampleAnalysisKey(
    val path: String,
    val lastModified: Long,
    val length: Long,
    val config: AbletonSampleVolumeAnalysisConfig
)

data class AbletonPitchEstimate

data class AbletonPitchEstimate(
    val midiPitch: Int,
    val velocity: Int,
    val startSeconds: Double,
    val endSeconds: Double,
    val channel: Int
) {

val frequencyHz: Double get() = midiToFrequency(midiPitch.toDouble())

data class AbletonClipPitch

data class AbletonClipPitch(
    val track: AbletonTrack,
    val clip: AbletonClip,
    val sourceFile: File,
    val sourceSeconds: Double,
    val pitch: AbletonPitchEstimate
)

data class AbletonTrackPitch

data class AbletonTrackPitch(
    val track: AbletonTrack,
    val arrangementBeat: Double,
    val arrangementSeconds: Double,
    val pitch: AbletonPitchEstimate,
    val clips: List<AbletonClipPitch>
)

data class AbletonSamplePitchAnalysis

data class AbletonSamplePitchAnalysis(
    val sourceFile: File,
    val midiFile: File?,
    val sourceLastModified: Long,
    val sourceLength: Long,
    val notes: List<AbletonPitchEstimate>
) {

fun dominantPitchAtSeconds(sourceSeconds: Double): AbletonPitchEstimate?

    fun dominantPitchAtSeconds(sourceSeconds: Double): AbletonPitchEstimate? =
        notes.asSequence()
            .filter { sourceSeconds >= it.startSeconds && sourceSeconds < it.endSeconds }
            .maxWithOrNull(compareBy<AbletonPitchEstimate> { it.velocity }.thenBy { it.startSeconds })

companion object

fun readMidi

        fun readMidi(
            sourceFile: File,
            midiFile: File
        ): AbletonSamplePitchAnalysis =
            AbletonSamplePitchAnalysis(
                sourceFile = sourceFile.absoluteFile.normalize(),
                midiFile = midiFile.absoluteFile.normalize(),
                sourceLastModified = sourceFile.lastModified(),
                sourceLength = sourceFile.length(),
                notes = readPitchNotes(midiFile)
            )

Read the note-on/note-off pairs out of a basic-pitch MIDI file.

The note pairing, tempo map and tick-to-seconds conversion this used to inline are now Sequence.readTimedNotes() in Midi.kt, which is the same code generalised — this function is the caller, and AbletonMidiReadCharacterizationTest is the fence that says the generalisation did not change what comes out. The sort is kept here rather than pushed down because it is this reader's contract, not the file format's: dominantPitchAtSeconds breaks velocity ties by picking the earlier note, which only works if equal-start notes arrive loudest-first.

        private fun readPitchNotes(midiFile: File): List<AbletonPitchEstimate> =
            MidiSystem.getSequence(midiFile).readTimedNotes()
                .map {
                    AbletonPitchEstimate(
                        midiPitch = it.pitch,
                        velocity = it.velocity,
                        startSeconds = it.startSeconds,
                        endSeconds = it.endSeconds,
                        channel = it.channel
                    )
                }
                .sortedWith(compareBy<AbletonPitchEstimate> { it.startSeconds }.thenByDescending { it.velocity })
    }
}

class AbletonSamplePitchAnalysisCache

class AbletonSamplePitchAnalysisCache(
    private val basicPitchExecutable: File? = configuredBasicPitchExecutable(),
    private val onsetThreshold: Double = configuredBasicPitchOnsetThreshold()
) {
    private val cache = ConcurrentHashMap<AbletonSamplePitchAnalysisKey, AbletonSamplePitchAnalysis>()

fun analysisFor(sourceFile: File): AbletonSamplePitchAnalysis

    fun analysisFor(sourceFile: File): AbletonSamplePitchAnalysis {
        val file = sourceFile.absoluteFile.normalize()
        val key = AbletonSamplePitchAnalysisKey(
            path = file.absolutePath,
            lastModified = file.lastModified(),
            length = file.length()
        )
        cache[key]?.let { return it }
        return cache.computeIfAbsent(key) { loadFromDiskOrRunBasicPitch(file, key) }
    }

fun dominantPitchAtSeconds(sourceFile: File, sourceSeconds: Double): AbletonPitchEstimate?

    fun dominantPitchAtSeconds(sourceFile: File, sourceSeconds: Double): AbletonPitchEstimate? =
        analysisFor(sourceFile).dominantPitchAtSeconds(sourceSeconds)

fun clear()

    fun clear() {
        cache.clear()
    }

    private fun loadFromDiskOrRunBasicPitch(file: File, key: AbletonSamplePitchAnalysisKey): AbletonSamplePitchAnalysis {
        findValidBasicPitchMidi(file, onsetThreshold)?.let { midi ->
            println("Ableton pitch analysis: loaded MIDI cache ${midi.name} for ${file.name}")
            return AbletonSamplePitchAnalysis.readMidi(file, midi)
        }

        val executable = basicPitchExecutable
        if (executable == null || !executable.isFile) {
            println(
                "Ableton pitch analysis: no valid MIDI cache for ${file.name}; " +
                    "configure AFIELD_BASIC_PITCH_EXE or -Dafield.basicPitch.exe to run basic-pitch"
            )
            return AbletonSamplePitchAnalysis(
                sourceFile = file,
                midiFile = null,
                sourceLastModified = key.lastModified,
                sourceLength = key.length,
                notes = emptyList()
            )
        }

        val startNs = System.nanoTime()
        println("Ableton pitch analysis: running ${executable.absolutePath} for ${file.absolutePath}")
        val midi = runBasicPitch(executable, file, onsetThreshold)
        val analysis = AbletonSamplePitchAnalysis.readMidi(file, midi)
        val elapsedMs = (System.nanoTime() - startNs) / 1_000_000.0
        println(
            "Ableton pitch analysis: cached ${file.name} notes=${analysis.notes.size}, " +
                "elapsed=${"%.1f".format(elapsedMs)}ms, disk=${midi.name}"
        )
        return analysis
    }

companion object

val shared = AbletonSamplePitchAnalysisCache()

private data class AbletonSamplePitchAnalysisKey(
    val path: String,
    val lastModified: Long,
    val length: Long
)

ActiveMidiNote and MidiTempoEvent used to live here, private to this file. They are now in Midi.kt, along with Sequence.tempoEvents(), tickToSeconds, ticksToSeconds and the note-on/note-off pairing loop — see readPitchNotes above, which is now a caller.

source — 68 lines, from line 1060
private fun configuredBasicPitchExecutable(): File? =
    (System.getProperty("afield.basicPitch.exe") ?: System.getenv("AFIELD_BASIC_PITCH_EXE"))
        ?.takeIf { it.isNotBlank() }
        ?.let { File(it).absoluteFile.normalize() }

private fun configuredBasicPitchOnsetThreshold(): Double =
    (System.getProperty("afield.basicPitch.onsetThreshold") ?: System.getenv("AFIELD_BASIC_PITCH_ONSET_THRESHOLD"))
        ?.toDoubleOrNull()
        ?: 0.9

private fun findValidBasicPitchMidi(sourceFile: File, onsetThreshold: Double = configuredBasicPitchOnsetThreshold()): File? {
    val deterministic = basicPitchMidiFileFor(sourceFile, onsetThreshold)
    if (deterministic.isValidPitchMidiFor(sourceFile)) return deterministic

    val parent = sourceFile.parentFile ?: return null
    val stem = sourceFile.nameWithoutExtension
    return parent.listFiles { file ->
        file.isFile &&
            file.extension.equals("mid", ignoreCase = true) &&
            !file.name.contains(".field4-basic-pitch-onset", ignoreCase = true) &&
            file.nameWithoutExtension.contains(stem, ignoreCase = true) &&
            file.isValidPitchMidiFor(sourceFile)
    }.orEmpty()
        .maxByOrNull { it.lastModified() }
}

private fun basicPitchMidiFileFor(sourceFile: File, onsetThreshold: Double): File =
    File(sourceFile.parentFile ?: File("."), "${sourceFile.name}.field4-basic-pitch-onset${onsetThreshold.cacheToken()}.mid")

private fun File.isValidPitchMidiFor(sourceFile: File): Boolean =
    isFile && lastModified() >= sourceFile.lastModified()

private fun runBasicPitch(executable: File, sourceFile: File, onsetThreshold: Double): File {
    val outputDirectory = sourceFile.parentFile ?: File(".")
    val beforeMs = System.currentTimeMillis() - 2_000L
    val process = ProcessBuilder(
        executable.absolutePath,
        outputDirectory.absolutePath,
        sourceFile.absolutePath,
        "--onset-threshold",
        onsetThreshold.toString()
    )
        .directory(outputDirectory)
        .redirectErrorStream(true)
        .start()

    val output = process.inputStream.bufferedReader().use { it.readText() }
    val exit = process.waitFor()
    if (exit != 0) {
        error("basic-pitch failed for ${sourceFile.absolutePath} with exit code $exit\n$output")
    }

    val generated = outputDirectory.listFiles { file ->
            file.isFile &&
            file.extension.equals("mid", ignoreCase = true) &&
            file.nameWithoutExtension.contains(sourceFile.nameWithoutExtension, ignoreCase = true) &&
            file.lastModified() >= beforeMs
    }.orEmpty()
        .maxByOrNull { it.lastModified() }
        ?: error("basic-pitch did not produce a MIDI file for ${sourceFile.absolutePath}\n$output")

    val deterministic = basicPitchMidiFileFor(sourceFile, onsetThreshold)
    if (generated.absoluteFile.normalize() != deterministic.absoluteFile.normalize()) {
        Files.move(generated.toPath(), deterministic.toPath(), StandardCopyOption.REPLACE_EXISTING)
    }
    deterministic.setLastModified(sourceFile.lastModified().coerceAtLeast(System.currentTimeMillis()))
    return deterministic
}

tempoEvents(), closeActiveNote, tickToSeconds and ticksToSeconds used to live here. They are in Midi.kt now — tempoEvents, tickToSeconds and ticksToSeconds under the same names and with unchanged bodies, and closeActiveNote generalised to emit MidiTimedNote instead of AbletonPitchEstimate so it can serve both this reader and readMidiClip.

data class AbletonAutomationEnvelope

data class AbletonAutomationEnvelope(
    val id: String,
    val target: AbletonAutomationTarget,
    val events: List<AbletonAutomationEvent>
) {

fun bracketAt(arrangementTime: Double): Pair<AbletonAutomationEvent?, AbletonAutomationEvent?>

    fun bracketAt(arrangementTime: Double): Pair<AbletonAutomationEvent?, AbletonAutomationEvent?> {
        if (events.isEmpty()) return null to null

        val sorted = sortedEvents()
        val leftIndex = sorted.indexOfLast { it.time <= arrangementTime }
        val left = sorted.getOrNull(leftIndex)
        val right = sorted.drop(leftIndex + 1).firstOrNull { it.time > arrangementTime }
        return left to right
    }

fun valueAt(arrangementTime: Double): AbletonAutomationValue

    fun valueAt(arrangementTime: Double): AbletonAutomationValue {
        if (events.isEmpty()) return AbletonAutomationValue.Missing

        val sorted = sortedEvents()
        val first = sorted.first()
        if (arrangementTime <= first.time) return first.value

        val leftIndex = sorted.indexOfLast { it.time <= arrangementTime }
        val left = sorted[leftIndex]
        val right = sorted.drop(leftIndex + 1).firstOrNull { it.time > arrangementTime }
            ?: return left.value

        return when {
            left.value is AbletonAutomationValue.FloatValue && right.value is AbletonAutomationValue.FloatValue -> {
                val t = ((arrangementTime - left.time) / (right.time - left.time)).coerceIn(0.0, 1.0)
                val shaped = left.curve?.xToY(t) ?: t
                AbletonAutomationValue.FloatValue(left.value.value + shaped * (right.value.value - left.value.value))
            }
            else -> left.value
        }
    }

    private fun sortedEvents(): List<AbletonAutomationEvent> =
        events.sortedWith(compareBy<AbletonAutomationEvent> { it.time }.thenBy { it.id.toDoubleOrNull() ?: 0.0 })

companion object

internal fun fromElement

        internal fun fromElement(
            envelope: Element,
            targets: Map<String, AbletonAutomationTarget>
        ): AbletonAutomationEnvelope? {
            val targetId = envelope.firstDescendant("EnvelopeTarget")
                ?.valueChild("PointeeId")
                ?: return null
            val events = envelope.firstDescendant("Automation")
                ?.firstDescendant("Events")
                ?.childElements()
                ?.mapNotNull(AbletonAutomationEvent::fromElement)
                ?.sortedWith(compareBy<AbletonAutomationEvent> { it.time }.thenBy { it.id.toDoubleOrNull() ?: 0.0 })
                ?: emptyList()
            return AbletonAutomationEnvelope(
                id = envelope.attr("Id") ?: "",
                target = targets[targetId] ?: AbletonAutomationTarget(
                    id = targetId,
                    name = "target:$targetId",
                    path = emptyList(),
                    deviceName = null,
                    parameterName = null,
                    manual = null,
                    range = null
                ),
                events = events
            )
        }
    }
}

data class AbletonAutomationTarget

data class AbletonAutomationTarget(
    val id: String,
    val name: String,
    val path: List<String>,
    val deviceName: String?,
    val parameterName: String?,
    val manual: AbletonAutomationValue?,
    val range: ClosedFloatingPointRange<Double>?
)

data class AbletonAutomationEvent

data class AbletonAutomationEvent(
    val id: String,
    val time: Double,
    val value: AbletonAutomationValue,
    val curve: AbletonAutomationCurve?
) {

companion object

internal fun fromElement(event: Element): AbletonAutomationEvent?

        internal fun fromElement(event: Element): AbletonAutomationEvent? {
            val id = event.attr("Id") ?: ""
            val time = event.attr("Time")?.toDoubleOrNull() ?: return null
            val rawValue = event.attr("Value") ?: return null
            val value = when (event.tagName) {
                "FloatEvent" -> AbletonAutomationValue.FloatValue(rawValue.toDoubleOrNull() ?: return null)
                "BoolEvent" -> AbletonAutomationValue.BoolValue(rawValue.toBooleanStrictOrNull() ?: return null)
                "EnumEvent" -> AbletonAutomationValue.EnumValue(rawValue.toIntOrNull() ?: return null)
                else -> return null
            }
            return AbletonAutomationEvent(
                id = id,
                time = time,
                value = value,
                curve = AbletonAutomationCurve.fromElement(event)
            )
        }
    }
}

sealed class AbletonAutomationValue

data class FloatValue(val value: Double) : AbletonAutomationValue()

data class BoolValue(val value: Boolean) : AbletonAutomationValue()

data class EnumValue(val value: Int) : AbletonAutomationValue()

data object Missing : AbletonAutomationValue()

fun asDoubleOrNull(): Double?

    fun asDoubleOrNull(): Double? = when (this) {
        is FloatValue -> value
        is EnumValue -> value.toDouble()
        is BoolValue -> if (value) 1.0 else 0.0
        Missing -> null
    }
}

data class AbletonAutomationCurve

data class AbletonAutomationCurve(
    val control1X: Double,
    val control1Y: Double,
    val control2X: Double,
    val control2Y: Double
) {

fun xToY(x: Double): Double

    fun xToY(x: Double): Double {
        var low = 0.0
        var high = 1.0
        repeat(24) {
            val mid = (low + high) * 0.5
            if (cubic(mid, control1X, control2X) < x) low = mid else high = mid
        }
        return cubic((low + high) * 0.5, control1Y, control2Y).coerceIn(0.0, 1.0)
    }

companion object

internal fun fromElement(event: Element): AbletonAutomationCurve?

        internal fun fromElement(event: Element): AbletonAutomationCurve? {
            val c1x = event.attr("CurveControl1X")?.toDoubleOrNull() ?: return null
            val c1y = event.attr("CurveControl1Y")?.toDoubleOrNull() ?: return null
            val c2x = event.attr("CurveControl2X")?.toDoubleOrNull() ?: return null
            val c2y = event.attr("CurveControl2Y")?.toDoubleOrNull() ?: return null
            return AbletonAutomationCurve(c1x, c1y, c2x, c2y)
        }

        private fun cubic(t: Double, c1: Double, c2: Double): Double {
            val u = 1.0 - t
            return 3.0 * u * u * t * c1 + 3.0 * u * t * t * c2 + t * t * t
        }
    }
}

data class AbletonTempoMap

data class AbletonTempoMap(
    val defaultBpm: Double,
    val events: List<AbletonTempoEvent> = emptyList()
) {

fun bpmAtBeat(beat: Double): Double

    fun bpmAtBeat(beat: Double): Double =
        events.lastOrNull { it.beat <= beat }?.bpm ?: defaultBpm

fun beatToSeconds(beat: Double): Double

    fun beatToSeconds(beat: Double): Double {
        if (events.none { it.beat >= 0.0 }) {
            return beat * 60.0 / defaultBpm
        }

        var seconds = 0.0
        var cursorBeat = 0.0
        var bpm = defaultBpm
        for (event in events.filter { it.beat >= 0.0 }.sortedBy { it.beat }) {
            if (event.beat >= beat) break
            seconds += (event.beat - cursorBeat) * 60.0 / bpm
            cursorBeat = event.beat
            bpm = event.bpm
        }
        seconds += (beat - cursorBeat) * 60.0 / bpm
        return seconds
    }

fun secondsToBeat(seconds: Double): Double

    fun secondsToBeat(seconds: Double): Double {
        if (events.none { it.beat >= 0.0 }) {
            return seconds * defaultBpm / 60.0
        }

        var cursorSeconds = 0.0
        var cursorBeat = 0.0
        var bpm = defaultBpm
        for (event in events.filter { it.beat >= 0.0 }.sortedBy { it.beat }) {
            val segmentSeconds = (event.beat - cursorBeat) * 60.0 / bpm
            if (cursorSeconds + segmentSeconds >= seconds) {
                return cursorBeat + (seconds - cursorSeconds) * bpm / 60.0
            }
            cursorSeconds += segmentSeconds
            cursorBeat = event.beat
            bpm = event.bpm
        }
        return cursorBeat + (seconds - cursorSeconds) * bpm / 60.0
    }

fun beatsToDurationSeconds(beats: Double, fromBeat: Double = 0.0): Double

    fun beatsToDurationSeconds(beats: Double, fromBeat: Double = 0.0): Double =
        beatToSeconds(fromBeat + beats) - beatToSeconds(fromBeat)

companion object

internal fun fromMasterTrack(masterTrack: Element?): AbletonTempoMap

        internal fun fromMasterTrack(masterTrack: Element?): AbletonTempoMap {
            val tempoManual = masterTrack
                ?.firstDescendant("Tempo")
                ?.valueChild("Manual")
                ?.toDoubleOrNull()
            val tempoEvents = masterTrack
                ?.childElements("AutomationEnvelopes")
                ?.firstOrNull()
                ?.descendants()
                ?.filter { it.tagName == "FloatEvent" }
                ?.mapNotNull {
                    val beat = it.attr("Time")?.toDoubleOrNull() ?: return@mapNotNull null
                    val bpm = it.attr("Value")?.toDoubleOrNull() ?: return@mapNotNull null
                    AbletonTempoEvent(beat, bpm)
                }
                ?.filter { it.bpm in 1.0..999.0 }
                ?.sortedBy { it.beat }
                ?.toList()
                ?: emptyList()
            val defaultBpm = tempoManual
                ?: tempoEvents.lastOrNull { it.beat <= 0.0 }?.bpm
                ?: 120.0
            return AbletonTempoMap(defaultBpm, tempoEvents)
        }
    }
}

data class AbletonTempoEvent

body — 113 lines, from line 1364
data class AbletonTempoEvent(
    val beat: Double,
    val bpm: Double
)

private fun List<AbletonWarpMarker>.sourceSecondsAtClipBeat(clipBeat: Double): Double {
    if (isEmpty()) return 0.0
    if (size == 1 || clipBeat <= first().clipBeat) {
        val first = first()
        val next = getOrNull(1)
        val slope = if (next != null && next.clipBeat != first.clipBeat) {
            (next.sourceSeconds - first.sourceSeconds) / (next.clipBeat - first.clipBeat)
        } else {
            0.5
        }
        return first.sourceSeconds + (clipBeat - first.clipBeat) * slope
    }

    for (index in 0 until lastIndex) {
        val left = this[index]
        val right = this[index + 1]
        if (clipBeat <= right.clipBeat) {
            val t = (clipBeat - left.clipBeat) / (right.clipBeat - left.clipBeat)
            return left.sourceSeconds + t * (right.sourceSeconds - left.sourceSeconds)
        }
    }

    val left = this[lastIndex - 1]
    val right = last()
    val slope = (right.sourceSeconds - left.sourceSeconds) / (right.clipBeat - left.clipBeat)
    return right.sourceSeconds + (clipBeat - right.clipBeat) * slope
}

private fun List<AbletonWarpMarker>.clipBeatAtSourceSecondsOrNull(sourceSeconds: Double): Double? {
    if (size < 2) return null
    if (sourceSeconds <= first().sourceSeconds) return first().clipBeat
    for (index in 0 until lastIndex) {
        val left = this[index]
        val right = this[index + 1]
        if (sourceSeconds <= right.sourceSeconds) {
            val t = (sourceSeconds - left.sourceSeconds) / (right.sourceSeconds - left.sourceSeconds)
            return left.clipBeat + t * (right.clipBeat - left.clipBeat)
        }
    }
    val left = this[lastIndex - 1]
    val right = last()
    val slope = (right.clipBeat - left.clipBeat) / (right.sourceSeconds - left.sourceSeconds)
    return right.clipBeat + (sourceSeconds - right.sourceSeconds) * slope
}

private infix fun Double.positiveMod(modulus: Double): Double =
    ((this % modulus) + modulus) % modulus

private fun AbletonClipPlayback.volumeContribution(
    analysisCache: AbletonSampleVolumeAnalysisCache
): AbletonClipVolume? {
    val file = sourceFile ?: return null
    val analysis = analysisCache.analysisFor(file)
    return AbletonClipVolume(
        track = track,
        clip = clip,
        sourceFile = file.absoluteFile.normalize(),
        sourceSeconds = sourceSeconds,
        bands = analysis.volumeAtSeconds(sourceSeconds)
    )
}

private fun AbletonClipPlayback.pitchContribution(
    pitchCache: AbletonSamplePitchAnalysisCache
): AbletonClipPitch? {
    val file = sourceFile ?: return null
    val pitch = pitchCache.dominantPitchAtSeconds(file, sourceSeconds) ?: return null
    return AbletonClipPitch(
        track = track,
        clip = clip,
        sourceFile = file.absoluteFile.normalize(),
        sourceSeconds = sourceSeconds,
        pitch = pitch
    )
}

private fun List<AbletonClipPitch>.dominantClipPitchOrNull(): AbletonClipPitch? =
    maxWithOrNull(
        compareBy<AbletonClipPitch> { it.pitch.velocity }
            .thenBy { it.pitch.startSeconds }
            .thenBy { it.pitch.midiPitch }
    )

private fun List<AbletonClipVolume>.rootSumSquareBands(): AbletonVolumeBands {
    if (isEmpty()) return AbletonVolumeBands.Zero
    var low = 0.0
    var mid = 0.0
    var high = 0.0
    for (clip in this) {
        low += clip.bands.low * clip.bands.low
        mid += clip.bands.mid * clip.bands.mid
        high += clip.bands.high * clip.bands.high
    }
    return AbletonVolumeBands(
        low = sqrt(low),
        mid = sqrt(mid),
        high = sqrt(high)
    )
}

private fun AbletonVolumeBands.lerp(other: AbletonVolumeBands, t: Double): AbletonVolumeBands {
    val u = 1.0 - t
    return AbletonVolumeBands(
        low = low * u + other.low * t,
        mid = mid * u + other.mid * t,
        high = high * u + other.high * t
    )
}

internal fun Element.attr(name: String): String?

internal fun Element.attr(name: String): String? =
    if (hasAttribute(name)) getAttribute(name) else null

internal fun Element.valueChild(name: String): String?

internal fun Element.valueChild(name: String): String? =
    childElements(name).firstOrNull()?.attr("Value")

internal fun Element.childElements(name: String? = null): List<Element>

internal fun Element.childElements(name: String? = null): List<Element> =
    (0 until childNodes.length)
        .asSequence()
        .map { childNodes.item(it) }
        .filterIsInstance<Element>()
        .filter { name == null || it.tagName == name }
        .toList()

internal fun Element.descendants(): Sequence<Element>

internal fun Element.descendants(): Sequence<Element> =
    childElements().asSequence().flatMap { sequenceOf(it) + it.descendants() }

internal fun Element.firstDescendant(name: String): Element?

body — 94 lines, from line 1495
internal fun Element.firstDescendant(name: String): Element? =
    descendants().firstOrNull { it.tagName == name }

private fun AbletonSampleRef.lookupKeys(): List<String> =
    listOfNotNull(relativePath, absolutePath)
        .flatMap { path ->
            val normalized = path.normalizedLookupPath()
            val withoutDotDots = normalized.split('/').filter { it != ".." && it != "." && it.isNotBlank() }
            buildList {
                if (normalized.isNotBlank()) add(normalized)
                if (withoutDotDots.isNotEmpty()) {
                    for (index in withoutDotDots.indices) {
                        add(withoutDotDots.drop(index).joinToString("/"))
                    }
                }
            }
        }
        .map { it.lowercase() }
        .distinct()

private fun AbletonSampleRef.fileNameKey(): String? =
    listOfNotNull(relativePath, absolutePath)
        .mapNotNull { it.normalizedLookupPath().substringAfterLast('/', "").takeIf(String::isNotBlank) }
        .firstOrNull()
        ?.lowercase()

private fun String.normalizedLookupPath(): String =
    replace('\\', '/')
        .split('/')
        .filter { it.isNotBlank() && it != "." }
        .joinToString("/")

private fun File.invariantPath(): String =
    path.replace(File.separatorChar, '/')

private fun Element.automationTargetsById(): Map<String, AbletonAutomationTarget> =
    descendants()
        .filter { it.tagName == "AutomationTarget" }
        .mapNotNull { target ->
            val id = target.attr("Id") ?: return@mapNotNull null
            val parameter = target.parentElement() ?: return@mapNotNull null
            val device = parameter.nearestDeviceLikeAncestor(this)
            val parameterName = parameter.tagName
            val deviceName = device?.displayName()
            val path = parameter.pathFrom(this)
            id to AbletonAutomationTarget(
                id = id,
                name = listOfNotNull(deviceName, parameterName).joinToString(".").ifEmpty { "target:$id" },
                path = path,
                deviceName = deviceName,
                parameterName = parameterName,
                manual = parameter.valueChild("Manual")?.let { manual ->
                    when {
                        manual.equals("true", ignoreCase = true) || manual.equals("false", ignoreCase = true) ->
                            AbletonAutomationValue.BoolValue(manual.toBooleanStrict())
                        manual.toIntOrNull() != null && parameter.firstDescendant("MidiControllerRange") == null ->
                            AbletonAutomationValue.EnumValue(manual.toInt())
                        else ->
                            manual.toDoubleOrNull()?.let(AbletonAutomationValue::FloatValue)
                    }
                },
                range = parameter.firstDescendant("MidiControllerRange")?.let { range ->
                    val min = range.valueChild("Min")?.toDoubleOrNull()
                    val max = range.valueChild("Max")?.toDoubleOrNull()
                    if (min != null && max != null) min..max else null
                }
            )
        }
        .toMap()

private fun Element.parentElement(): Element? =
    parentNode as? Element

private fun Element.ancestorsUntil(root: Element): Sequence<Element> =
    generateSequence(parentElement()) { it.parentElement() }
        .takeWhile { it != root.parentElement() }

private fun Element.nearestDeviceLikeAncestor(root: Element): Element? =
    ancestorsUntil(root).firstOrNull { candidate ->
        val parent = candidate.parentElement()
        parent?.tagName == "Devices" || candidate.tagName in setOf("Mixer", "MainSequencer", "FreezeSequencer")
    }

private fun Element.displayName(): String {
    val userName = valueChild("UserName")
    if (!userName.isNullOrBlank()) return userName
    val effectiveName = childElements("Name").firstOrNull()?.valueChild("EffectiveName")
    if (!effectiveName.isNullOrBlank()) return effectiveName
    return tagName
}

private fun Element.pathFrom(root: Element): List<String> =
    (ancestorsUntil(root).takeWhile { it != root }.map { it.tagName }.toList().asReversed() + tagName)
        .filterNot { it in setOf("LomId", "LomIdView") }