AbletonSetSmokeTest.kt
src/jvmMain/kotlin/afield/audio/synth/AbletonSetSmokeTest.kt · 529 lines
package afield.audio.synth
import java.nio.file.Files
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.zip.GZIPOutputStream
import javax.sound.midi.MetaMessage
import javax.sound.midi.MidiEvent
import javax.sound.midi.MidiSystem
import javax.sound.midi.Sequence
import javax.sound.midi.ShortMessage
import kotlin.math.abs
import kotlin.math.PI
import kotlin.math.sin
FIELD_TEST id: examples.ableton-set.smoke runner: jvm-main entrypoint: afield.audio.synth.AbletonSetSmokeTest tags: [smoke, ableton, xml, audio] timeoutSec: 20 expect: exitCode: 0 stdoutContains: - "AbletonSet smoke tests passed"
Smoke test for reading Ableton Live sets: clips, automation envelopes, gzipped .als files, sample
lookup, track volume and pitch analysis, run against generated fixtures.
object AbletonSetSmokeTest
Runs the Ableton set checks; given a path to a set, prints a summary of its tracks instead.
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
if (args.isNotEmpty()) {
printSummary(args.first(), args.getOrNull(1))
return
}
check(runTests()) { "AbletonSet smoke tests failed" }
}
fun runTests(): Boolean
body — 482 lines, from line 47
fun runTests(): Boolean {
return try {
testClipEnumerationAndPlaybackLookup()
testAutomationEnvelopeLookupAndEvaluation()
testGzippedAbletonFile()
testSampleFileMapper()
testTrackVolumeAnalysis()
testPitchAnalysisFromMidiSidecar()
testDebugFLines()
println("AbletonSet smoke tests passed")
true
} catch (t: Throwable) {
t.printStackTrace()
false
}
}
private fun testClipEnumerationAndPlaybackLookup() {
val xml = Files.createTempFile("ableton-set", ".xml")
Files.writeString(xml, fixtureXml())
val set = AbletonSet.read(xml.toFile())
require(set.tempoMap.defaultBpm == 120.0) { "Expected 120 BPM, got ${set.tempoMap.defaultBpm}" }
require(set.tracks.size == 1) { "Expected one track, got ${set.tracks.size}" }
val track = set.tracks.single()
require(track.name == "Woven") { "Expected track name Woven, got ${track.name}" }
require(track.clips.size == 2) { "Expected two clips, got ${track.clips.size}" }
val atStart = set.clipsPlayingAtBeat(0.0).single()
require(atStart.clip.name == "Scene 1")
require(close(atStart.sourceSeconds, 0.0)) {
"Expected source start 0.0s, got ${atStart.sourceSeconds}"
}
val scene2 = set.clipsPlayingAtSeconds(272.25).single()
require(scene2.clip.name == "Scene 2")
require(close(scene2.arrangementBeat, 544.5)) {
"Expected beat 544.5, got ${scene2.arrangementBeat}"
}
require(close(scene2.sourceSeconds, 272.25)) {
"Expected source 272.25s, got ${scene2.sourceSeconds}"
}
require(set.clipsPlayingAtBeat(543.9).isEmpty()) {
"Expected gap before Scene 2"
}
}
private fun testAutomationEnvelopeLookupAndEvaluation() {
val xml = Files.createTempFile("ableton-set-automation", ".xml")
Files.writeString(xml, fixtureXml())
val set = AbletonSet.read(xml.toFile())
val automation = set.tracks.single().automation.single()
require(automation.target.name == "Chorus2.Rate") {
"Expected Chorus2.Rate target, got ${automation.target.name}"
}
require(automation.target.manual == AbletonAutomationValue.FloatValue(0.9))
require(automation.target.range == 0.1..15.0)
require(automation.valueAt(-10.0) == AbletonAutomationValue.FloatValue(0.9))
require(automation.valueAt(10.0) == AbletonAutomationValue.FloatValue(10.0))
val middle = automation.valueAt(5.0).asDoubleOrNull()
require(middle != null && close(middle, 5.45)) {
"Expected interpolated automation value 5.45, got $middle"
}
// Query before the first breakpoint. Ableton anchors the envelope's opening
// value at the -63072000 sentinel, so "pre-first" means a time earlier than
// that, not merely a negative time (-20 sits between the sentinel and t=0).
val before = automation.bracketAt(-70_000_000.0)
require(before.first == null && before.second?.time == -63072000.0) {
"Expected pre-first bracket null..-63072000, got $before"
}
val exact = automation.bracketAt(0.0)
require(exact.first?.time == 0.0 && exact.second?.time == 10.0) {
"Expected exact bracket 0..10, got $exact"
}
val between = automation.bracketAt(5.0)
require(between.first?.time == 0.0 && between.second?.time == 10.0) {
"Expected between bracket 0..10, got $between"
}
val after = automation.bracketAt(20.0)
require(after.first?.time == 10.0 && after.second == null) {
"Expected post-last bracket 10..null, got $after"
}
}
private fun testGzippedAbletonFile() {
val als = Files.createTempFile("ableton-set", ".als")
GZIPOutputStream(Files.newOutputStream(als)).bufferedWriter().use {
it.write(fixtureXml())
}
val set = AbletonSet.read(als.toFile())
require(set.tracks.single().clips.size == 2) {
"Expected two clips from gzipped Ableton file, got ${set.tracks.single().clips.size}"
}
val scene2 = set.clipsPlayingAtSeconds(272.25).single()
require(scene2.clip.name == "Scene 2")
}
private fun testSampleFileMapper() {
AbletonSampleFileMapper.clearCache()
val xml = Files.createTempFile("ableton-set-samples", ".xml")
Files.writeString(xml, fixtureXml())
val set = AbletonSet.read(xml.toFile())
val searchRoot = Files.createTempDirectory("ableton-samples").toFile()
val sample = searchRoot.resolve("Samples/Imported/Woven.wav")
sample.parentFile.mkdirs()
sample.writeText("not actually audio")
val mapper = AbletonSampleFileMapper.forDirectory(searchRoot)
val resolved = mapper.resolve(set.clips.first())
require(resolved == sample.absoluteFile.normalize()) {
"Expected mapper to resolve Woven.wav to $sample, got $resolved"
}
val playback = set.clipsPlayingAtBeat(0.0, mapper).single()
require(playback.sourceFile == sample.absoluteFile.normalize()) {
"Expected playback to carry resolved sample file, got ${playback.sourceFile}"
}
require(playback.sourcePath == sample.absoluteFile.normalize().absolutePath) {
"Expected playback sourcePath to prefer resolved sample file, got ${playback.sourcePath}"
}
require(AbletonSampleFileMapper.forDirectory(searchRoot) === mapper) {
"Expected mapper to be cached per search directory"
}
require(set.resolveSamples(searchRoot).values.toSet() == setOf(sample.absoluteFile.normalize())) {
"Expected set-level sample resolution to use cached mapper"
}
}
private fun testTrackVolumeAnalysis() {
AbletonSampleFileMapper.clearCache()
val xml = Files.createTempFile("ableton-set-volume", ".xml")
Files.writeString(xml, fixtureXml())
val set = AbletonSet.read(xml.toFile())
val searchRoot = Files.createTempDirectory("ableton-volume-samples").toFile()
val sample = searchRoot.resolve("Samples/Imported/Woven.wav")
sample.parentFile.mkdirs()
writeMonoSineWav(sample, sampleRate = 8000, seconds = 2.0, frequency = 110.0)
val mapper = AbletonSampleFileMapper.forDirectory(searchRoot)
val cacheConfig = AbletonSampleVolumeAnalysisConfig(
windowSeconds = 0.050,
hopSeconds = 0.010,
lowCutoffHz = 250.0,
highCutoffHz = 2_000.0,
fftSize = 1024
)
val cache = AbletonSampleVolumeAnalysisCache(cacheConfig)
val volumes = set.trackVolumesAtBeat(1.0, mapper, cache)
require(volumes.size == 1) { "Expected one sounding track, got ${volumes.size}" }
val volume = volumes.single()
require(volume.track.name == "Woven") { "Expected Woven volume, got ${volume.track.name}" }
require(volume.clips.size == 1) { "Expected one clip contribution, got ${volume.clips.size}" }
require(close(volume.clips.single().sourceSeconds, 0.5)) {
"Expected beat 1.0 to map to source 0.5s, got ${volume.clips.single().sourceSeconds}"
}
require(volume.bands.low > 0.01f) { "Expected low-band energy from 110Hz sine, got ${volume.bands}" }
require(volume.bands.low > volume.bands.high) { "Expected low band above high band, got ${volume.bands}" }
val bySeconds = set.trackVolumesAtSeconds(0.5, mapper, cache).single()
require(close(bySeconds.arrangementBeat, 1.0)) {
"Expected 0.5 arrangement seconds to map to beat 1.0, got ${bySeconds.arrangementBeat}"
}
val sidecars = sample.parentFile.listFiles { file ->
file.name.startsWith("${sample.name}.field4-volume-") && file.name.endsWith(".bin")
}.orEmpty()
require(sidecars.size == 1) { "Expected one persisted volume sidecar, got ${sidecars.toList()}" }
require(sidecars.single().lastModified() >= sample.lastModified()) {
"Expected sidecar mtime to be at least sample mtime"
}
val diskBackedCache = AbletonSampleVolumeAnalysisCache(cacheConfig)
val diskBackedVolume = set.trackVolumesAtBeat(1.0, mapper, diskBackedCache).single()
require(close(diskBackedVolume.bands.low, volume.bands.low)) {
"Expected disk-backed low band ${volume.bands.low}, got ${diskBackedVolume.bands.low}"
}
}
private fun testPitchAnalysisFromMidiSidecar() {
AbletonSampleFileMapper.clearCache()
val xml = Files.createTempFile("ableton-set-pitch", ".xml")
Files.writeString(xml, fixtureXml())
val set = AbletonSet.read(xml.toFile())
val searchRoot = Files.createTempDirectory("ableton-pitch-samples").toFile()
val sample = searchRoot.resolve("Samples/Imported/Woven.wav")
sample.parentFile.mkdirs()
writeMonoSineWav(sample, sampleRate = 8000, seconds = 2.0, frequency = 440.0)
val midi = sample.parentFile.resolve("${sample.name}.field4-basic-pitch-onset0p9.mid")
writePitchMidi(midi)
midi.setLastModified(sample.lastModified().coerceAtLeast(System.currentTimeMillis()))
val mapper = AbletonSampleFileMapper.forDirectory(searchRoot)
val pitchCache = AbletonSamplePitchAnalysisCache(basicPitchExecutable = null)
val samplePitch = pitchCache.dominantPitchAtSeconds(sample, 0.5)
require(samplePitch?.midiPitch == 67) { "Expected sample pitch 67 at 0.5s, got $samplePitch" }
require(samplePitch.velocity == 110) { "Expected dominant velocity 110, got ${samplePitch.velocity}" }
val clipPitch = set.dominantPitchesAtSeconds(0.5, mapper, pitchCache).single()
require(clipPitch.track.name == "Woven") { "Expected Woven pitch, got ${clipPitch.track.name}" }
require(close(clipPitch.sourceSeconds, 0.5)) {
"Expected arrangement 0.5s to map to source 0.5s, got ${clipPitch.sourceSeconds}"
}
require(clipPitch.pitch.midiPitch == 67) { "Expected clip pitch 67, got ${clipPitch.pitch}" }
val trackPitch = set.dominantPitchesByTrackAtSeconds(0.5, mapper, pitchCache).single()
require(trackPitch.track.name == "Woven") { "Expected Woven track pitch, got ${trackPitch.track.name}" }
require(trackPitch.pitch.midiPitch == 67) { "Expected track pitch 67, got ${trackPitch.pitch}" }
require(trackPitch.clips.single().clip.name == "Scene 1") {
"Expected Scene 1 contribution, got ${trackPitch.clips}"
}
}
private fun writePitchMidi(file: java.io.File) {
val sequence = Sequence(Sequence.PPQ, 480)
val track = sequence.createTrack()
val tempo = MetaMessage()
tempo.setMessage(0x51, byteArrayOf(0x07, 0xA1.toByte(), 0x20), 3)
track.add(MidiEvent(tempo, 0))
addNote(track, pitch = 60, velocity = 80, startTick = 0, endTick = 720)
addNote(track, pitch = 67, velocity = 110, startTick = 240, endTick = 960)
MidiSystem.write(sequence, 1, file)
}
private fun addNote(
track: javax.sound.midi.Track,
pitch: Int,
velocity: Int,
startTick: Long,
endTick: Long
) {
val on = ShortMessage()
on.setMessage(ShortMessage.NOTE_ON, 0, pitch, velocity)
track.add(MidiEvent(on, startTick))
val off = ShortMessage()
off.setMessage(ShortMessage.NOTE_OFF, 0, pitch, 0)
track.add(MidiEvent(off, endTick))
}
private fun writeMonoSineWav(file: java.io.File, sampleRate: Int, seconds: Double, frequency: Double) {
val frames = (sampleRate * seconds).toInt()
val dataBytes = frames * 2
val bytes = ByteBuffer.allocate(44 + dataBytes).order(ByteOrder.LITTLE_ENDIAN)
bytes.put("RIFF".toByteArray(Charsets.US_ASCII))
bytes.putInt(36 + dataBytes)
bytes.put("WAVE".toByteArray(Charsets.US_ASCII))
bytes.put("fmt ".toByteArray(Charsets.US_ASCII))
bytes.putInt(16)
bytes.putShort(1)
bytes.putShort(1)
bytes.putInt(sampleRate)
bytes.putInt(sampleRate * 2)
bytes.putShort(2)
bytes.putShort(16)
bytes.put("data".toByteArray(Charsets.US_ASCII))
bytes.putInt(dataBytes)
for (i in 0 until frames) {
val sample = (sin(2.0 * PI * frequency * i.toDouble() / sampleRate.toDouble()) * 0.6 * Short.MAX_VALUE)
.toInt()
.toShort()
bytes.putShort(sample)
}
Files.write(file.toPath(), bytes.array())
}
private fun testDebugFLines() {
val xml = Files.createTempFile("ableton-set-debug-lines", ".xml")
Files.writeString(xml, fixtureXml())
val set = AbletonSet.read(xml.toFile())
val lines = set.toDebugFLines {
pixelsPerBeat = 1.0
automationSamples = 12
includeGuides = true
markAutomationEvents = true
}
require(lines.isNotEmpty()) { "Expected debug FLine output" }
require(lines.any { it.properties["ableton.kind"] == "clip" && it.properties["ableton.clip.name"] == "Scene 1" }) {
"Expected debug FLine output to include Scene 1 clip"
}
require(lines.any { it.properties["ableton.kind"] == "automation" && it.properties["ableton.automation.target"] == "Chorus2.Rate" }) {
"Expected debug FLine output to include Chorus2.Rate automation"
}
require(lines.any { it.properties["ableton.kind"] == "automation-event" }) {
"Expected debug FLine output to include automation event markers"
}
require(lines.all { it.nodes.isNotEmpty() }) {
"Expected every debug FLine to contain geometry"
}
}
private fun printSummary(path: String, sampleSearchRoot: String? = null) {
val set = AbletonSet.read(path)
val sampleMapper = sampleSearchRoot?.let { AbletonSampleFileMapper.forDirectory(java.io.File(it)) }
println("Ableton XML: ${set.source.absolutePath}")
sampleMapper?.let {
println("sample search: ${it.searchDirectory.absolutePath}")
}
println(
"tempo: ${set.tempoMap.defaultBpm} BPM, tracks: ${set.tracks.size}, " +
"clips: ${set.clips.size}, automation: ${set.automationEnvelopes.size}"
)
for (track in set.tracks) {
println("${track.index}: ${track.name} (${track.kind}) clips=${track.clips.size} automation=${track.automation.size}")
track.clips.take(8).forEach { clip ->
val startSeconds = set.tempoMap.beatToSeconds(clip.currentStartBeat)
val endSeconds = set.tempoMap.beatToSeconds(clip.currentEndBeat)
val source = clip.sample?.relativePath ?: clip.sample?.absolutePath ?: "no sample"
val resolved = sampleMapper?.resolve(clip)?.absolutePath
println(
" ${clip.name}: beats ${clip.currentStartBeat}..${clip.currentEndBeat} " +
"seconds ${"%.3f".format(startSeconds)}..${"%.3f".format(endSeconds)} " +
"source=$source${resolved?.let { " resolved=$it" } ?: ""}"
)
}
track.automation.take(4).forEach { envelope ->
val first = envelope.events.firstOrNull()
val last = envelope.events.lastOrNull()
println(
" automation ${envelope.target.name} target=${envelope.target.id} " +
"events=${envelope.events.size} time=${first?.time}..${last?.time} " +
"manual=${envelope.target.manual}"
)
}
if (track.clips.size > 8) println(" ...")
if (track.automation.size > 4) println(" ... automation")
}
set.mainTrack?.let { track ->
println("${track.index}: ${track.name} (${track.kind}) clips=${track.clips.size} automation=${track.automation.size}")
track.automation.take(4).forEach { envelope ->
val first = envelope.events.firstOrNull()
val last = envelope.events.lastOrNull()
println(
" automation ${envelope.target.name} target=${envelope.target.id} " +
"events=${envelope.events.size} time=${first?.time}..${last?.time} " +
"manual=${envelope.target.manual}"
)
}
}
}
private fun close(a: Double, b: Double): Boolean = abs(a - b) < 1.0e-6
private fun fixtureXml(): String =
"""
<?xml version="1.0" encoding="UTF-8"?>
<Ableton>
<LiveSet>
<Tracks>
<AudioTrack Id="8">
<Name>
<EffectiveName Value="Woven" />
<UserName Value="" />
</Name>
<Color Value="25" />
<AutomationEnvelopes>
<Envelopes>
<AutomationEnvelope Id="0">
<EnvelopeTarget>
<PointeeId Value="900" />
</EnvelopeTarget>
<Automation>
<Events>
<FloatEvent Id="0" Time="-63072000" Value="0.9" />
<FloatEvent Id="1" Time="0" Value="0.9" />
<FloatEvent Id="2" Time="10" Value="10" />
</Events>
</Automation>
</AutomationEnvelope>
</Envelopes>
</AutomationEnvelopes>
<DeviceChain>
<Devices>
<Chorus2 Id="1">
<Rate>
<Manual Value="0.9" />
<MidiControllerRange>
<Min Value="0.1" />
<Max Value="15" />
</MidiControllerRange>
<AutomationTarget Id="900">
<LockEnvelope Value="0" />
</AutomationTarget>
</Rate>
</Chorus2>
</Devices>
<MainSequencer>
<Sample>
<ArrangerAutomation>
<Events>
<AudioClip Id="17" Time="0">
<CurrentStart Value="0" />
<CurrentEnd Value="540" />
<Loop>
<LoopStart Value="0" />
<LoopEnd Value="270" />
<StartRelative Value="0" />
<LoopOn Value="false" />
<OutMarker Value="770.1977291666667" />
</Loop>
<Name Value="Scene 1" />
<Disabled Value="false" />
<SampleRef>
<FileRef>
<RelativePath Value="Samples/Imported/Woven.wav" />
<Path Value="/tmp/Woven.wav" />
<OriginalFileSize Value="222992236" />
<OriginalCrc Value="1539" />
</FileRef>
<DefaultDuration Value="36969491" />
<DefaultSampleRate Value="48000" />
</SampleRef>
<IsWarped Value="false" />
<WarpMarkers>
<WarpMarker Id="1" SecTime="0" BeatTime="0" />
<WarpMarker Id="2" SecTime="0.015625" BeatTime="0.03125" />
</WarpMarkers>
</AudioClip>
<AudioClip Id="18" Time="544">
<CurrentStart Value="544" />
<CurrentEnd Value="741.25" />
<Loop>
<LoopStart Value="272" />
<LoopEnd Value="370.625" />
<StartRelative Value="0" />
<LoopOn Value="false" />
</Loop>
<Name Value="Scene 2" />
<SampleRef>
<FileRef>
<RelativePath Value="Samples/Imported/Woven.wav" />
</FileRef>
</SampleRef>
<IsWarped Value="false" />
<WarpMarkers>
<WarpMarker Id="3" SecTime="272" BeatTime="0" />
<WarpMarker Id="4" SecTime="272.015625" BeatTime="0.03125" />
</WarpMarkers>
</AudioClip>
</Events>
</ArrangerAutomation>
</Sample>
</MainSequencer>
</DeviceChain>
</AudioTrack>
</Tracks>
<MasterTrack Id="0">
<AutomationEnvelopes>
<Envelopes>
<AutomationEnvelope Id="1">
<Automation>
<Events>
<FloatEvent Id="0" Time="-63072000" Value="120" />
</Events>
</Automation>
</AutomationEnvelope>
</Envelopes>
</AutomationEnvelopes>
<DeviceChain>
<Mixer>
<Tempo>
<Manual Value="120" />
</Tempo>
</Mixer>
</DeviceChain>
</MasterTrack>
</LiveSet>
</Ableton>
""".trimIndent()
}