ControlPresets.kt
src/jvmMain/kotlin/afield/ui/output/ControlPresets.kt · 425 lines
package afield.ui.output
import afield.animation.gesture.reduceTakeIndices
import afield.animation.keyframes.Keyframe
import afield.animation.keyframes.KeyframeEvaluator
import afield.animation.keyframes.KeyframeScope
import afield.animation.keyframes.keyframes
import afield.app.diagnostics.logging.lprintln
import afield.app.protocol.ControllerControl
import afield.app.server.broadcastControllerControlUpdates
import afield.audio.synth.MidiInput
import afield.document.graph.Box
import afield.document.workspace.FileManager
import afield.ui.controls.ControlLaw
import afield.ui.controls.ControlLawSpec
import afield.math.linalg.Vector2f
import afield.math.linalg.Vector4f
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import javax.sound.midi.ShortMessage
import kotlin.io.path.exists
Presets, recording and MIDI learn for controlGroup controls.
A preset is a keyframer key: declare the controls with the keyframe* helpers (keyframer.kt), and
presetBar stores, recalls and blends KeyframeScope keys — saved with version history, browsable
in the keyframe collection browser. Recording and MIDI learn address a control by its control key,
type/label#ordinal (slider/speed#0): the label-derived part of the id a control group already
gives each control, so a key names the same control after a rerun, a restart, and on the tablet.
Moving live controls from code (recording playback, knobs)
The kinds of control a knob or a recording can move.
private val valueControlTypes = setOf("slider", "vec2", "color", "checkbox", "string")
internal fun applyControlChanges(box: Box, channel: String, changes: Map<String, (ControllerControl) -> ControllerControl?>): List<String> {
Applies changes (control key -> new record, or null to leave it) to box's channel controls,
shows them, and calls each changed control's callback as if a person had moved it. Only the
in-memory rerun cache moves: a knob or a replay makes many changes a second. Returns the keys changed.
internal fun applyControlChanges(box: Box, channel: String, changes: Map<String, (ControllerControl) -> ControllerControl?>): List<String> {
val changed = mutableListOf<String>()
val document = box.takeIf { it.isInsideInitialized() }?.inside?.filename
controlGroupControlsWithKeys(box, channel).forEach { (key, control) ->
val next = changes[key]?.invoke(control) ?: return@forEach
if (next == control) return@forEach
val updated = updateControllerControlValueProgrammatically(control.id) { next }
if (updated.isEmpty()) return@forEach
document?.let { broadcastControllerControlUpdates(updated, it) }
invokeControlCallback(next)
changed += key
}
return changed
}
private fun invokeControlCallback(control: ControllerControl) {
runCatching {
when (control.type) {
"slider" -> control.value?.let { getSliderCallback(control.id)?.invoke(it) }
"vec2" -> getVec2Callback(control.id)?.invoke(Vector2f(control.x!!.toFloat(), control.y!!.toFloat()))
"color" -> getColorCallback(control.id)?.invoke(
Vector4f(control.r!!.toFloat(), control.g!!.toFloat(), control.b!!.toFloat(), control.a!!.toFloat())
)
"checkbox" -> control.checked?.let { getCheckboxCallback(control.id)?.invoke(it) }
"string" -> control.text?.let { getStringCallback(control.id)?.invoke(it) }
}
}.onFailure { lprintln("error,controls", "control callback for '${control.label}' threw: ${it.message}") }
}
private fun clampTo(v: Double, a: Double, b: Double) = v.coerceIn(minOf(a, b), maxOf(a, b))
internal fun controlKeyForLabel(box: Box, channel: String, label: String): String?
The control key for the control labelled label (of a movable kind) in box's channel, or null.
internal fun controlKeyForLabel(box: Box, channel: String, label: String): String? =
controlGroupControlsWithKeys(box, channel).firstOrNull { it.second.label == label && it.second.type in valueControlTypes }?.first
Gesture listeners: every change a person makes, from the browser or the tablet
private val controlGestureListeners = CopyOnWriteArrayList<(Box, ControllerControl) -> Unit>()
internal fun notifyControlGestureListeners(box: Box, control: ControllerControl)
internal fun notifyControlGestureListeners(box: Box, control: ControllerControl) {
controlGestureListeners.forEach { runCatching { it(box, control) } }
}
internal fun addControlGestureListener(listener: (Box, ControllerControl) -> Unit) { controlGestureListeners += listener }
internal fun addControlGestureListener(listener: (Box, ControllerControl) -> Unit) { controlGestureListeners += listener }
internal fun removeControlGestureListener(listener: (Box, ControllerControl) -> Unit) { controlGestureListeners -= listener }
internal fun removeControlGestureListener(listener: (Box, ControllerControl) -> Unit) { controlGestureListeners -= listener }
Presets: keys of a KeyframeScope
const val presetMorphLabel = "preset morph"
The label of the blend slider presetBar adds. It is not a scope target, so no key holds it.
class PresetBar internal constructor(val scope: KeyframeScope, val directory: Path)
The keys a presetBar stores, in directory: one file per name, written by KeyframeScope.saveKey
so each store adds to that key's version history and the folder opens in keyframeBrowser.
class PresetBar internal constructor(val scope: KeyframeScope, val directory: Path) {
val names: List<String> get() = scope.savedKeyNames(directory)
Saved key names, sorted.
fun path(name: String): Path = directory.resolve(name)
Where key name lives.
fun store(name: String, note: String? = null): KeyframeScope.Key
Captures the scope as key name (a new version if it exists) and returns it.
fun store(name: String, note: String? = null): KeyframeScope.Key = scope.capture().save(path(name), note)
fun recall(name: String): Boolean
Applies the latest save of name; false if there is none.
fun recall(name: String): Boolean {
val p = path(name)
if (!p.exists()) return false
scope.loadKey(p)()
return true
}
fun morph(t: Double, between: List<String> = emptyList()): KeyframeScope.Key?
Applies the blend at t (0..1) along between, in order — every saved key when empty — via
KeyframeScope.interpolateAlong. Returns the applied key, or null with fewer than two keys.
fun morph(t: Double, between: List<String> = emptyList()): KeyframeScope.Key? {
val chosen = between.ifEmpty { names }.filter { path(it).exists() }
if (chosen.size < 2) return null
return scope.interpolateAlong(chosen.map { scope.loadKey(path(it)) }, t).also { it() }
}
}
fun Box.presetDirectory(channel: String = "controls"): Path
Where a presetBar on channel keeps its keys by default: keys/<box id>/<channel> in the
document's files — also the folder to hand keyframeBrowser, or to seed with key.save(...).
fun Box.presetDirectory(channel: String = "controls"): Path = FileManager.current().path("%document/keys/$id/$channel")
fun ControlGroupBuilder.presetBar
A preset strip for the controls of scope: a name field with store and recall, a button per
saved key, and a presetMorphLabel slider blending across morphBetween (every saved key, in
name order, when empty). Controls join by being declared with the keyframe* helpers on scope.
val keys = KeyframeScope()
controlGroup("look") {
keyframeSlider(keys, "size", "size", 0.0, 10.0, get = { size }, set = { size = it })
presetBar(keys)
}
Keys are saved in directory, by default presetDirectory for this box and channel.
fun ControlGroupBuilder.presetBar(
scope: KeyframeScope,
directory: Path? = null,
morphBetween: List<String> = emptyList(),
morph: Boolean = true
): PresetBar {
val bar = PresetBar(scope, directory ?: ownerBox.presetDirectory(ownerChannel))
val saved = bar.names
var name = saved.lastOrNull() ?: "A"
inlineRow {
text("preset", initialValue = name) { name = it.trim().ifEmpty { "A" } }
button("store preset", "store") { down -> if (down) bar.store(name) }
button("recall preset", "recall") { down -> if (down) bar.recall(name) }
}
if (saved.isNotEmpty()) inlineRow {
saved.forEach { key -> button("▸ $key", "▸ $key") { down -> if (down) bar.recall(key) } }
}
if (morph) slider(presetMorphLabel, 0.0, 1.0, 0.001, initialValue = 0.0) { t -> bar.morph(t, morphBetween) }
return bar
}
Recording a control into keyframes
class ControlRecording internal constructor
A take of one control: every change a person makes to it while armed, with its time.
stop bakes the take into a keyframes(...) editor named curveName, thinned so no key strays
more than tolerance (a fraction of the control's range) from what was played.
class ControlRecording internal constructor(
val box: Box,
val channel: String,
val label: String,
val curveName: String,
val tolerance: Double
) {
private val samples = CopyOnWriteArrayList<Pair<Long, ControllerControl>>()
@Volatile var armed
@Volatile var armed = true
private set
private val listener: (Box, ControllerControl) -> Unit = { owner, control ->
if (armed && owner === box && control.channel == channel && control.label == label) {
samples += System.nanoTime() to control
}
}
init { addControlGestureListener(listener) }
val sampleCount: Int get() = samples.size
Number of changes captured so far.
fun capture(atSeconds: Double, value: Double)
Adds a sample by hand — what a test, or code generating a gesture, uses in place of a hand.
fun capture(atSeconds: Double, value: Double) {
val control = controlGroupControlsWithKeys(box, channel).firstOrNull { it.second.label == label }?.second ?: return
samples += (atSeconds * 1e9).toLong() to control.copy(value = value, x = value)
}
fun keys(): List<Keyframe> = bake { it.value ?: it.x ?: 0.0 }
The take so far as keys (time in seconds from the first change). A vec2 gives x; use keysY for y.
fun keysY(): List<Keyframe> = bake { it.y ?: 0.0 }
The y lane of a vec2 take.
private fun bake(pick: (ControllerControl) -> Double): List<Keyframe> {
val s = samples.toList().sortedBy { it.first }
if (s.isEmpty()) return emptyList()
val t0 = s.first().first
val range = s.first().second.let { (if (it.type == "vec2") it.xMax - it.xMin else it.max - it.min).let { r -> if (r == 0.0) 1.0 else kotlin.math.abs(r) } }
val take = DoubleArray(s.size * 3)
s.forEachIndexed { i, (t, c) ->
take[i * 3] = (t - t0) / 1e9
take[i * 3 + 1] = pick(c)
take[i * 3 + 2] = pick(c)
}
return reduceTakeIndices(take, tolerance * range).map { Keyframe(take[it * 3], take[it * 3 + 1], "linear") }
}
fun stop(): KeyframeEvaluator
Disarms and writes the take into the curve curveName (and $curveName.y for a vec2).
fun stop(): KeyframeEvaluator {
armed = false
removeControlGestureListener(listener)
val curve = box.keyframes(name = curveName)
val keys = keys()
if (keys.isNotEmpty()) curve.setKeys(keys)
val first = samples.firstOrNull()?.second
if (first?.type == "vec2") box.keyframes(name = "$curveName.y").also { y -> keysY().takeIf { it.isNotEmpty() }?.let(y::setKeys) }
return curve
}
}
fun Box.recordControl(label: String, channel: String = "controls", curveName: String = label, tolerance: Double = 0.005): ControlRecording
Arms a recording of the control labelled label in channel; see ControlRecording.
fun Box.recordControl(label: String, channel: String = "controls", curveName: String = label, tolerance: Double = 0.005): ControlRecording =
ControlRecording(this, channel, label, curveName, tolerance)
fun Box.playControl(label: String, curve: KeyframeEvaluator, time: Double, channel: String = "controls", curveY: KeyframeEvaluator? = null): Boolean
Plays curve at time into the control labelled label: the control moves and its callback runs.
Call it from an animation loop. A vec2 also needs curveY.
fun Box.playControl(label: String, curve: KeyframeEvaluator, time: Double, channel: String = "controls", curveY: KeyframeEvaluator? = null): Boolean {
val key = controlKeyForLabel(this, channel, label) ?: return false
val v = curve.evaluateValueAt(time)
val change: (ControllerControl) -> ControllerControl? = { c ->
when (c.type) {
"vec2" -> c.copy(x = clampTo(v, c.xMin, c.xMax), y = clampTo(curveY?.evaluateValueAt(time) ?: v, c.yMin, c.yMax))
"slider" -> c.copy(value = clampTo(v, c.min, c.max))
else -> null
}
}
return applyControlChanges(this, channel, mapOf(key to change)).isNotEmpty()
}
fun ControlGroupBuilder.recordToggle(label: String, curveName: String = label, tolerance: Double = 0.005)
A record button for the control labelled label: press it, move the control, press it again, and
the take lands in the keyframe curve curveName (opened below), ready to edit and to playControl
back. A button rather than a checkbox because a checkbox's state is restored when the box runs again,
which would arm a recording nobody asked for.
fun ControlGroupBuilder.recordToggle(label: String, curveName: String = label, tolerance: Double = 0.005) {
val box = ownerBox
val channel = ownerChannel
val key = "${box.id}\u0000$channel\u0000$label"
button("● rec $label") { down ->
if (!down) return@button
val running = activeRecordings.remove(key)
if (running == null) {
activeRecordings[key] = box.recordControl(label, channel, curveName, tolerance)
lprintln("controls", "recording '$label' — press again to stop")
} else {
running.stop()
lprintln("controls", "recorded '$label': ${running.sampleCount} changes → ${running.keys().size} keys in '$curveName'")
}
}
}
private val activeRecordings = ConcurrentHashMap<String, ControlRecording>()
MIDI learn
internal const val controlMidiProperty = "_controlMidi"
data class MidiBinding(val key: String, val midiChannel: Int, val cc: Int)
A hardware controller cc on MIDI midiChannel (0-based) bound to the control with control key key.
class ControlMidiMap internal constructor(val box: Box, val channel: String)
Knob bindings for one control group, saved with the box.
learn arms: the next control you touch and the next controller you turn — in either order — are
bound. A bound CC moves the control along its own travel (a log slider stays log under the knob),
a checkbox flips at the knob's midpoint. attach a MidiInput to hear hardware; feed is the same
entry for anything else that produces controller values (RTP-MIDI, OSC, a test).
class ControlMidiMap internal constructor(val box: Box, val channel: String) {
@Volatile private var learning = false
@Volatile private var pendingKey: String? = null
@Volatile private var pendingCc: Pair<Int, Int>? = null
private val inputs = CopyOnWriteArrayList<MidiInput>()
val isLearning: Boolean get() = learning
True while waiting for a control and a CC to bind.
val bindings: List<MidiBinding>
The current bindings.
val bindings: List<MidiBinding>
get() {
val stored = (box.properties[controlMidiProperty] as? Map<*, *>)?.get(channel) as? Map<*, *> ?: return emptyList()
return stored.mapNotNull { (key, v) ->
val m = v as? Map<*, *> ?: return@mapNotNull null
val ch = (m["channel"] as? Number)?.toInt() ?: return@mapNotNull null
val cc = (m["cc"] as? Number)?.toInt() ?: return@mapNotNull null
MidiBinding(key.toString(), ch, cc)
}
}
private val listener: (Box, ControllerControl) -> Unit = { owner, control ->
if (learning && owner === box && control.channel == channel && control.label != "MIDI learn" && control.type in valueControlTypes) {
pendingKey = controlGroupControlsWithKeys(box, channel).firstOrNull { it.second.id == control.id }?.first
tryBind()
}
}
init { addControlGestureListener(listener) }
fun learn()
Arms learning; see the class doc.
fun learn() {
learning = true
pendingKey = null
pendingCc = null
}
fun cancel() { learning = false }
Disarms without binding.
fun bind(label: String, cc: Int, midiChannel: Int = 0): Boolean
Binds cc on midiChannel to the control labelled label, in code.
fun bind(label: String, cc: Int, midiChannel: Int = 0): Boolean {
val key = controlKeyForLabel(box, channel, label) ?: return false
writeBinding(MidiBinding(key, midiChannel, cc))
return true
}
fun unbind(label: String)
Removes the binding for the control labelled label.
fun unbind(label: String) {
val key = controlKeyForLabel(box, channel, label) ?: return
write(bindings.filterNot { it.key == key })
}
fun feed(midiChannel: Int, cc: Int, value: Int)
A controller value arriving: learns it if armed, then drives whatever it is bound to.
fun feed(midiChannel: Int, cc: Int, value: Int) {
if (learning) {
pendingCc = midiChannel to cc
if (tryBind()) return
}
val t = value.coerceIn(0, 127) / 127.0
val targets = bindings.filter { it.midiChannel == midiChannel && it.cc == cc }.map { it.key }.toSet()
if (targets.isEmpty()) return
applyControlChanges(box, channel, targets.associateWith { { control: ControllerControl -> travelValue(control, t) } })
}
fun attach(input: MidiInput)
Listens to hardware input; detach by closing it.
fun attach(input: MidiInput) {
if (inputs.any { it === input }) return
inputs += input
input.onMessage { message, _ ->
if (message is ShortMessage && message.command == ShortMessage.CONTROL_CHANGE) feed(message.channel, message.data1, message.data2)
}
}
private fun tryBind(): Boolean {
val key = pendingKey ?: return false
val (ch, cc) = pendingCc ?: return false
writeBinding(MidiBinding(key, ch, cc))
learning = false
pendingKey = null
pendingCc = null
lprintln("controls", "MIDI learn: CC $cc on channel ${ch + 1} → $key")
return true
}
private fun writeBinding(binding: MidiBinding) {
// One knob per control and one control per knob: a re-learn moves the binding.
write(bindings.filterNot { it.key == binding.key || (it.cc == binding.cc && it.midiChannel == binding.midiChannel) } + binding)
}
private fun write(list: List<MidiBinding>) {
val all = LinkedHashMap<String, Any?>()
(box.properties[controlMidiProperty] as? Map<*, *>)?.forEach { (k, v) -> all[k.toString()] = v }
all[channel] = LinkedHashMap<String, Any>().apply { list.forEach { this[it.key] = mapOf("channel" to it.midiChannel, "cc" to it.cc) } }
box.setPropertyRaw(controlMidiProperty, all, doResync = false)
}
}
internal fun travelValue(control: ControllerControl, t: Double): ControllerControl?
control at travel t (0..1), as a knob would place it.
internal fun travelValue(control: ControllerControl, t: Double): ControllerControl? = when (control.type) {
"slider" -> control.copy(
value = ControlLawSpec(ControlLaw.fromWireName(control.law), control.min, control.max, control.lawExponent, control.lawCenter).valueAt(t)
)
"checkbox" -> control.copy(checked = t >= 0.5)
"vec2" -> control.copy(x = control.xMin + (control.xMax - control.xMin) * t)
else -> null
}
private val midiMaps = ConcurrentHashMap<String, ControlMidiMap>()
fun Box.midiLearn(channel: String = "controls"): ControlMidiMap
The MIDI map for channel, one per box and channel however often the box runs; see ControlMidiMap.
fun Box.midiLearn(channel: String = "controls"): ControlMidiMap =
midiMaps.computeIfAbsent("$id\u0000$channel") { ControlMidiMap(this, channel) }
fun ControlGroupBuilder.midiLearnButton(input: MidiInput? = null)
A MIDI learn button: press it, touch a control, turn a knob.
fun ControlGroupBuilder.midiLearnButton(input: MidiInput? = null) {
val map = ownerBox.midiLearn(ownerChannel)
input?.let(map::attach)
button("MIDI learn") { down -> if (down) map.learn() }
}