Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pair<Int, Int>, 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Pair<Int, List<Signal>>>

KeyframesChainDevice().apply {
state.update {
it.copy(
frames = frames
frames = frames,
infinity = keepLastFrameOn
)
}

Expand All @@ -221,7 +232,10 @@ object KeyLED {

return KeyframesChainDeviceContract.KeyframesChainDeviceState(
frames = frames,
renderedAnimation = renderedAnimation
renderedAnimation = renderedAnimation,
infinity = keepLastFrameOn,
useOwnershipTracking = true,
ownershipId = UUID.randomUUID()
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -784,26 +786,32 @@ class KeyframesChainDevice : LEDChainDevice<KeyframesChainDeviceState>(), 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

Expand All @@ -812,8 +820,14 @@ class KeyframesChainDevice : LEDChainDevice<KeyframesChainDeviceState>(), 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)) {
Expand Down Expand Up @@ -878,6 +892,30 @@ class KeyframesChainDevice : LEDChainDevice<KeyframesChainDeviceState>(), Chokea
}
}

private fun filterOwnershipSignals(
signals: List<Signal>,
ownerUUID: String
): List<Signal> {
val out = mutableListOf<Signal>()
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<Int>, targetStartIndex: Int? = null) {
if (frameIndices.isEmpty()) return

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pair<Int, List<Signal>>> = emptyList(),
) : DeviceState()
Expand Down