diff --git a/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/UnipadConverter.kt b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/UnipadConverter.kt index c7931fd5..50ae932d 100644 --- a/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/UnipadConverter.kt +++ b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/UnipadConverter.kt @@ -45,7 +45,8 @@ object UnipadConverter : AmethystConverter { println("Detected ZIP root prefix: '$rootPrefix' — re-indexing entries") val reindexed = entries.values .filter { it.path.startsWith(rootPrefix) } - .associateBy { it.path.removePrefix(rootPrefix) } + .map { it.copy(path = it.path.removePrefix(rootPrefix)) } + .associateBy { it.path } entries.clear() entries.putAll(reindexed) println("Entries after re-indexing: ${entries.size}") @@ -162,7 +163,7 @@ object UnipadConverter : AmethystConverter { page = index, entries = entries.filter { it.key.lowercase().startsWith("keyled/${index + 1} ") || - it.key.lowercase() == "keyled/${index + 1}" + it.key.lowercase() == "keyled/${index + 1}" }.map { it.key } ) ).also { diff --git a/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/UnipadOwnershipTracker.kt b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/UnipadOwnershipTracker.kt new file mode 100644 index 00000000..903f2d3f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/UnipadOwnershipTracker.kt @@ -0,0 +1,36 @@ +package dev.anthonyhfm.amethyst.conversion.unipad + +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.update + +/** + * Single-slot LED ownership tracker — mirrors Unipad's LedRunner.btnLed[x][y]. + * + * Each pad has one "owner" (the animation that most recently sent an On). + * An Off only clears the pad if the sender is still the owner; stale Offs + * are silently dropped — exactly like Unipad. + * + * Opt-in only: activated by KeyframesChainDeviceState.useOwnershipTracking. + */ +internal object UnipadOwnershipTracker { + private val owners = atomic(mapOf, String>()) // (x,y) -> ownerUUID + + fun claimOwnership(x: Int, y: Int, ownerUUID: String) { + owners.update { it + (x to y to ownerUUID) } + } + + /** Returns true if [ownerUUID] is still the owner; clears ownership if so. */ + fun checkAndReleaseOwnership(x: Int, y: Int, ownerUUID: String): Boolean { + var released = false + owners.update { current -> + if (current[x to y] == ownerUUID) { + released = true + current - (x to y) + } else { + released = false + current + } + } + return released + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/data/KeyLED.kt b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/data/KeyLED.kt index 7eca616f..20756328 100644 --- a/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/data/KeyLED.kt +++ b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/conversion/unipad/data/KeyLED.kt @@ -8,6 +8,8 @@ import dev.anthonyhfm.amethyst.core.util.Timing import dev.anthonyhfm.amethyst.devices.effects.coordinate_filter.CoordinateFilterChainDeviceState import dev.anthonyhfm.amethyst.devices.effects.group.GroupChainDeviceState import dev.anthonyhfm.amethyst.devices.effects.group.data.Group +import dev.anthonyhfm.amethyst.core.util.UUID +import dev.anthonyhfm.amethyst.core.util.randomUUID import dev.anthonyhfm.amethyst.devices.effects.keyframes.KeyframesChainDevice import dev.anthonyhfm.amethyst.devices.effects.keyframes.KeyframesChainDeviceContract import dev.anthonyhfm.amethyst.devices.effects.macro_filter.MacroFilterChainDeviceState @@ -205,12 +207,21 @@ object KeyLED { } } + val addedFinalFrame = currentFrame.entries.isNotEmpty() && currentFrame.entries != frames.lastOrNull()?.entries + if (addedFinalFrame) { + frames.add(currentFrame) + } + + val keepLastFrameOn = frames.lastOrNull()?.entries + ?.any { it.r != 0f || it.g != 0f || it.b != 0f } == true + val renderedAnimation: List>> KeyframesChainDevice().apply { state.update { it.copy( - frames = frames + frames = frames, + infinity = keepLastFrameOn ) } @@ -221,7 +232,10 @@ object KeyLED { return KeyframesChainDeviceContract.KeyframesChainDeviceState( frames = frames, - renderedAnimation = renderedAnimation + renderedAnimation = renderedAnimation, + infinity = keepLastFrameOn, + useOwnershipTracking = true, + ownershipId = UUID.randomUUID() ) } diff --git a/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/devices/effects/keyframes/KeyframesChainDevice.kt b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/devices/effects/keyframes/KeyframesChainDevice.kt index 4e826997..18a951ed 100644 --- a/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/devices/effects/keyframes/KeyframesChainDevice.kt +++ b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/devices/effects/keyframes/KeyframesChainDevice.kt @@ -16,6 +16,8 @@ import com.composeunstyled.theme.Theme import dev.anthonyhfm.amethyst.conversion.ableton.utils.MidiFileImporter import dev.anthonyhfm.amethyst.core.controls.selection.Selectable import dev.anthonyhfm.amethyst.core.engine.heaven.Heaven +import dev.anthonyhfm.amethyst.core.engine.heaven.isLit +import dev.anthonyhfm.amethyst.conversion.unipad.UnipadOwnershipTracker import dev.anthonyhfm.amethyst.core.engine.elements.Signal import dev.anthonyhfm.amethyst.core.controls.selection.SelectionManager import dev.anthonyhfm.amethyst.core.util.Timing @@ -784,26 +786,32 @@ class KeyframesChainDevice : LEDChainDevice(), Chokea } private fun startPlayback(triggerSignal: Signal.LED) { - val state = state.value - val repeats = state.repeats - val animation = state.renderedAnimation + val stateSnapshot = state.value + val repeats = stateSnapshot.repeats + val animation = stateSnapshot.renderedAnimation val totalDuration = animation.lastOrNull()?.first ?: 0 - val identifier = if (state.playbackMode == PlaybackMode.Poly) triggerSignal.x * 10 + triggerSignal.y else null + val identifier = if (stateSnapshot.playbackMode == PlaybackMode.Poly) triggerSignal.x * 10 + triggerSignal.y else null for (r in 0 until repeats) { val offset = r * totalDuration animation.forEach { (time, signals) -> Heaven.schedule(offset + time.toDouble(), owner = this, identifier = identifier) { + val s = state.value val transformed = transformSignals(signals, triggerSignal) - signalExit?.invoke(transformed) + val outgoing = if (s.useOwnershipTracking && s.ownershipId.isNotEmpty()) { + filterOwnershipSignals(transformed, s.ownershipId) + } else { + transformed + } + if (outgoing.isNotEmpty()) signalExit?.invoke(outgoing) } } } } private fun startLoopPlayback(triggerSignal: Signal.LED, identifier: Int) { - val state = state.value - val animation = state.renderedAnimation + val stateSnapshot = state.value + val animation = stateSnapshot.renderedAnimation val totalDuration = (animation.lastOrNull()?.first ?: 0).toDouble() if (totalDuration <= 0) return @@ -812,8 +820,14 @@ class KeyframesChainDevice : LEDChainDevice(), Chokea animation.forEach { (time, signals) -> Heaven.schedule(loopOffset + time, owner = this, identifier = identifier) { + val s = state.value val transformed = transformSignals(signals, triggerSignal) - signalExit?.invoke(transformed) + val outgoing = if (s.useOwnershipTracking && s.ownershipId.isNotEmpty()) { + filterOwnershipSignals(transformed, s.ownershipId) + } else { + transformed + } + if (outgoing.isNotEmpty()) signalExit?.invoke(outgoing) // If this is the last frame of the animation, schedule the next loop if (time.toDouble() == totalDuration && heldSignals.contains(identifier)) { @@ -878,6 +892,30 @@ class KeyframesChainDevice : LEDChainDevice(), Chokea } } + private fun filterOwnershipSignals( + signals: List, + ownerUUID: String + ): List { + val out = mutableListOf() + for (sig in signals) { + if (sig !is Signal.LED) { + out.add(sig) + continue + } + if (sig.color.isLit()) { + // On → claim ownership (last-writer-wins, like Unipad btnLed[x][y] = ...) + UnipadOwnershipTracker.claimOwnership(sig.x, sig.y, ownerUUID) + out.add(sig) + } else { + // Off → only forward if we're still the owner (stale Off → drop, like Unipad) + if (UnipadOwnershipTracker.checkAndReleaseOwnership(sig.x, sig.y, ownerUUID)) { + out.add(sig) + } + } + } + return out + } + fun duplicateFrames(frameIndices: List, targetStartIndex: Int? = null) { if (frameIndices.isEmpty()) return diff --git a/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/devices/effects/keyframes/KeyframesChainDeviceContract.kt b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/devices/effects/keyframes/KeyframesChainDeviceContract.kt index 59a38b5d..ef4bf80d 100644 --- a/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/devices/effects/keyframes/KeyframesChainDeviceContract.kt +++ b/composeApp/src/commonMain/kotlin/dev/anthonyhfm/amethyst/devices/effects/keyframes/KeyframesChainDeviceContract.kt @@ -60,6 +60,8 @@ sealed interface KeyframesChainDeviceContract { val isolate: Boolean = false, val pinch: Float = 0f, // Range [-2,2] val bilateralPinch: Boolean = false, + val useOwnershipTracking: Boolean = false, + val ownershipId: String = "", @Transient val renderedAnimation: List>> = emptyList(), ) : DeviceState()