From ea15c19e70088f11f1cada5bd04b3fd7559e15e5 Mon Sep 17 00:00:00 2001 From: Moritz Ruth Date: Tue, 23 May 2023 00:25:19 +0200 Subject: [PATCH] commit #2 --- build.gradle.kts | 6 + .../kotlin/de/moritzruth/lampenfieber/Main.kt | 45 +--- .../lampenfieber/device/FuturelightDmh160.kt | 20 ++ .../lampenfieber/device/SimpleDimmer.kt | 20 ++ .../StairvilleTriLedBar.kt} | 15 +- .../kotlin/de/moritzruth/theaterdsl/Act.kt | 5 - .../kotlin/de/moritzruth/theaterdsl/Runner.kt | 59 ----- .../kotlin/de/moritzruth/theaterdsl/Scene.kt | 5 - .../kotlin/de/moritzruth/theaterdsl/Step.kt | 42 ---- .../de/moritzruth/theaterdsl/StepTrigger.kt | 37 --- .../theaterdsl/{ => device}/Device.kt | 5 +- .../theaterdsl/{ => device}/DynamicValue.kt | 27 +- .../theaterdsl/dmx/EnttecOpenDmxUsb.kt | 9 +- .../de/moritzruth/theaterdsl/show/Act.kt | 10 + .../moritzruth/theaterdsl/{ => show}/Dsl.kt | 50 +++- .../de/moritzruth/theaterdsl/show/Runner.kt | 230 ++++++++++++++++++ .../de/moritzruth/theaterdsl/show/Scene.kt | 6 + .../de/moritzruth/theaterdsl/show/Show.kt | 65 +++++ .../de/moritzruth/theaterdsl/show/Step.kt | 48 ++++ .../de/moritzruth/theaterdsl/show/StepCue.kt | 63 +++++ .../theaterdsl/util/DerivedStateFlow.kt | 37 +++ .../util/DurationAsMillisecondsSerializer.kt | 20 ++ .../InstantAsEpochMillisecondsSerializer.kt | 19 ++ .../theaterdsl/util/StringWithDetails.kt | 18 ++ src/main/resources/simplelogger.properties | 1 + versions.properties | 6 +- 26 files changed, 653 insertions(+), 215 deletions(-) create mode 100644 src/main/kotlin/de/moritzruth/lampenfieber/device/FuturelightDmh160.kt create mode 100644 src/main/kotlin/de/moritzruth/lampenfieber/device/SimpleDimmer.kt rename src/main/kotlin/de/moritzruth/lampenfieber/{FloorBarDevice.kt => device/StairvilleTriLedBar.kt} (59%) delete mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/Act.kt delete mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/Runner.kt delete mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/Scene.kt delete mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/Step.kt delete mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/StepTrigger.kt rename src/main/kotlin/de/moritzruth/theaterdsl/{ => device}/Device.kt (62%) rename src/main/kotlin/de/moritzruth/theaterdsl/{ => device}/DynamicValue.kt (87%) create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/show/Act.kt rename src/main/kotlin/de/moritzruth/theaterdsl/{ => show}/Dsl.kt (75%) create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/show/Runner.kt create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/show/Scene.kt create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/show/Show.kt create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/show/Step.kt create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/show/StepCue.kt create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/util/DerivedStateFlow.kt create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/util/DurationAsMillisecondsSerializer.kt create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/util/InstantAsEpochMillisecondsSerializer.kt create mode 100644 src/main/kotlin/de/moritzruth/theaterdsl/util/StringWithDetails.kt create mode 100644 src/main/resources/simplelogger.properties diff --git a/build.gradle.kts b/build.gradle.kts index ed115b5..a353de1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") + kotlin("plugin.serialization") application } @@ -23,6 +24,11 @@ dependencies { implementation(KotlinX.collections.immutable) implementation(KotlinX.serialization.json) implementation(KotlinX.datetime) + implementation(Ktor.server.core) + implementation(Ktor.server.websockets) + implementation(Ktor.server.cio) + implementation(Ktor.server.contentNegotiation) + implementation(Ktor.plugins.serialization.kotlinx.json) implementation("org.slf4j:slf4j-simple:_") implementation("com.fazecast:jSerialComm:_") implementation("io.github.oshai:kotlin-logging-jvm:_") diff --git a/src/main/kotlin/de/moritzruth/lampenfieber/Main.kt b/src/main/kotlin/de/moritzruth/lampenfieber/Main.kt index d223d47..27c5640 100644 --- a/src/main/kotlin/de/moritzruth/lampenfieber/Main.kt +++ b/src/main/kotlin/de/moritzruth/lampenfieber/Main.kt @@ -1,50 +1,23 @@ package de.moritzruth.lampenfieber -import de.moritzruth.theaterdsl.StepTrigger -import de.moritzruth.theaterdsl.createAct +import de.moritzruth.lampenfieber.device.StairvilleTriLedBar import de.moritzruth.theaterdsl.dmx.DmxAddress import de.moritzruth.theaterdsl.dmx.EnttecOpenDmxUsb -import de.moritzruth.theaterdsl.runShow -import de.moritzruth.theaterdsl.value.Color -import de.moritzruth.theaterdsl.value.degrees -import de.moritzruth.theaterdsl.value.percent -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds +import de.moritzruth.theaterdsl.show.createShow +import de.moritzruth.theaterdsl.show.runShow +import kotlinx.collections.immutable.persistentSetOf -val bar = FloorBarDevice(DmxAddress(15u)) +val bar = StairvilleTriLedBar(DmxAddress(15u)) -val firstAct = createAct("Erster Akt") { - scene("Prolog") { - step { - trigger = StepTrigger.MusicStart("1. Ouvertüre: Dissonanzen in Schwarz und Weiß", 2.minutes) +val show = createShow { + act("Erster Akt") { + scene("Prolog") { - actors { - + "Sophie / von links" - } - - bar.color.static(Color(100.degrees, 100.percent, 100.percent)) - bar.brightness.switch(100.percent, 0.percent, 10.seconds) -// bar.color.fade(Color(100.degrees, 100.percent, 100.percent), 2.seconds) - } - - step { - trigger = StepTrigger.MusicEnd - - actors { - - "Sophie" - } } } } suspend fun main() { EnttecOpenDmxUsb.start() - runShow( - devices = setOf( - bar - ), - acts = listOf( - firstAct - ) - ) + runShow(show, persistentSetOf(bar)) } \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/lampenfieber/device/FuturelightDmh160.kt b/src/main/kotlin/de/moritzruth/lampenfieber/device/FuturelightDmh160.kt new file mode 100644 index 0000000..a02a4ca --- /dev/null +++ b/src/main/kotlin/de/moritzruth/lampenfieber/device/FuturelightDmh160.kt @@ -0,0 +1,20 @@ +package de.moritzruth.lampenfieber.device + +import de.moritzruth.theaterdsl.device.Device +import de.moritzruth.theaterdsl.device.PercentageDV +import de.moritzruth.theaterdsl.dmx.DmxAddress +import de.moritzruth.theaterdsl.dmx.DmxDataWriter +import kotlinx.collections.immutable.persistentSetOf + +class FuturelightDmh160(override val firstChannel: DmxAddress) : Device { + override val numberOfChannels: UInt = 16u + + override fun writeDmxData(writer: DmxDataWriter) { + writer.writePercentage(brightness.getCurrentValue()) + } + + val pan = AngleDV() + val brightness = PercentageDV() + + override val dvs = persistentSetOf(brightness) +} \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/lampenfieber/device/SimpleDimmer.kt b/src/main/kotlin/de/moritzruth/lampenfieber/device/SimpleDimmer.kt new file mode 100644 index 0000000..cb36bbb --- /dev/null +++ b/src/main/kotlin/de/moritzruth/lampenfieber/device/SimpleDimmer.kt @@ -0,0 +1,20 @@ +package de.moritzruth.lampenfieber.device + +import de.moritzruth.theaterdsl.device.Device +import de.moritzruth.theaterdsl.device.PercentageDV +import de.moritzruth.theaterdsl.dmx.DmxAddress +import de.moritzruth.theaterdsl.dmx.DmxDataWriter +import kotlinx.collections.immutable.persistentSetOf + +class SimpleDimmer(channel: DmxAddress) : Device { + override val firstChannel = channel + override val numberOfChannels: UInt = 1u + + override fun writeDmxData(writer: DmxDataWriter) { + writer.writePercentage(brightness.getCurrentValue()) + } + + val brightness = PercentageDV() + + override val dvs = persistentSetOf(brightness) +} \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/lampenfieber/FloorBarDevice.kt b/src/main/kotlin/de/moritzruth/lampenfieber/device/StairvilleTriLedBar.kt similarity index 59% rename from src/main/kotlin/de/moritzruth/lampenfieber/FloorBarDevice.kt rename to src/main/kotlin/de/moritzruth/lampenfieber/device/StairvilleTriLedBar.kt index 246fb56..03cff41 100644 --- a/src/main/kotlin/de/moritzruth/lampenfieber/FloorBarDevice.kt +++ b/src/main/kotlin/de/moritzruth/lampenfieber/device/StairvilleTriLedBar.kt @@ -1,10 +1,13 @@ -package de.moritzruth.lampenfieber +package de.moritzruth.lampenfieber.device -import de.moritzruth.theaterdsl.* +import de.moritzruth.theaterdsl.device.ColorDV +import de.moritzruth.theaterdsl.device.Device +import de.moritzruth.theaterdsl.device.PercentageDV import de.moritzruth.theaterdsl.dmx.DmxAddress import de.moritzruth.theaterdsl.dmx.DmxDataWriter +import kotlinx.collections.immutable.persistentSetOf -class FloorBarDevice(override val firstChannel: DmxAddress): Device { +class StairvilleTriLedBar(override val firstChannel: DmxAddress) : Device { override val numberOfChannels: UInt = 5u override fun writeDmxData(writer: DmxDataWriter) { @@ -19,4 +22,10 @@ class FloorBarDevice(override val firstChannel: DmxAddress): Device { val color = ColorDV() val brightness = PercentageDV() val strobeSpeed = PercentageDV() + + override val dvs = persistentSetOf( + color, + brightness, + strobeSpeed + ) } \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/Act.kt b/src/main/kotlin/de/moritzruth/theaterdsl/Act.kt deleted file mode 100644 index 378564d..0000000 --- a/src/main/kotlin/de/moritzruth/theaterdsl/Act.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.moritzruth.theaterdsl - -import kotlinx.collections.immutable.ImmutableList - -class Act(val name: String, val scenes: ImmutableList) \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/Runner.kt b/src/main/kotlin/de/moritzruth/theaterdsl/Runner.kt deleted file mode 100644 index e4e9407..0000000 --- a/src/main/kotlin/de/moritzruth/theaterdsl/Runner.kt +++ /dev/null @@ -1,59 +0,0 @@ -package de.moritzruth.theaterdsl - -import de.moritzruth.theaterdsl.dmx.EnttecOpenDmxUsb -import de.moritzruth.theaterdsl.dmx.PerDeviceDmxDataWriter -import kotlinx.coroutines.* -import kotlin.math.roundToLong -import kotlin.system.exitProcess -import kotlin.system.measureTimeMillis -import kotlin.time.Duration - -suspend fun runShow(devices: Set, acts: List) = coroutineScope { - launch { - while(isActive) { - val took = measureTimeMillis { - val data = UByteArray(512) - val writer = PerDeviceDmxDataWriter(data) - - for (device in devices) { - writer.reset(0, device.firstChannel, device.numberOfChannels) - device.writeDmxData(writer) - } - - EnttecOpenDmxUsb.data = data - } - - delay(((1000.0 / 30) - took).roundToLong()) - } - } - - var lastStepJob: Job? = null - - for (act in acts) { - println("ACT: ${act.name}\n") - - for (scene in act.scenes) { - println("SCENE: ${scene.name}") - - for (step in scene.steps) { - println("NEXT STEP: ${step.trigger.format()}") - val input = readln() - if (input === "q") exitProcess(0) - - lastStepJob?.cancelAndJoin() - lastStepJob = launch(SupervisorJob()) { - val context = object : StepRunContext, CoroutineScope by this { - - } - - with(step) { - runner?.let { context.it() } - } - } - } - } - } - - println("\nEND OF THE SHOW") - delay(Duration.INFINITE) -} \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/Scene.kt b/src/main/kotlin/de/moritzruth/theaterdsl/Scene.kt deleted file mode 100644 index 63f1b82..0000000 --- a/src/main/kotlin/de/moritzruth/theaterdsl/Scene.kt +++ /dev/null @@ -1,5 +0,0 @@ -package de.moritzruth.theaterdsl - -import kotlinx.collections.immutable.ImmutableList - -class Scene(val name: String, val steps: ImmutableList) \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/Step.kt b/src/main/kotlin/de/moritzruth/theaterdsl/Step.kt deleted file mode 100644 index 6e50623..0000000 --- a/src/main/kotlin/de/moritzruth/theaterdsl/Step.kt +++ /dev/null @@ -1,42 +0,0 @@ -package de.moritzruth.theaterdsl - -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.coroutines.CoroutineScope - -enum class PropPosition { - PROSCENIUM_LEFT, - PROSCENIUM_CENTER, - PROSCENIUM_RIGHT, - LEFT, - CENTER, - RIGHT, - BACKDROP -} - -@TheaterDslMarker -interface StepRunContext: CoroutineScope - -typealias StepRunner = (StepRunContext.() -> Unit) - -/** - * A string which has optional additional details separated by " / " - */ -@JvmInline -value class StringWithDetails(val value: String) { - companion object { - const val DELIMITER = " / " - } - - val main get() = value.split(DELIMITER)[0] - val details: String? get() = value.split(DELIMITER, limit = 2).takeIf { it.size == 2 }?.let { it[1] } - val hasDetails get() = details !== null -} - -class Step( - val trigger: StepTrigger, - val actorEntrances: ImmutableSet, - val actorExits: ImmutableSet, - val actorsOnStage: ImmutableList, - val runner: StepRunner? -) diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/StepTrigger.kt b/src/main/kotlin/de/moritzruth/theaterdsl/StepTrigger.kt deleted file mode 100644 index 5c2e3d8..0000000 --- a/src/main/kotlin/de/moritzruth/theaterdsl/StepTrigger.kt +++ /dev/null @@ -1,37 +0,0 @@ -package de.moritzruth.theaterdsl - -import kotlin.time.Duration - -sealed interface StepTrigger { - fun format(): String - - data class MusicStart(val title: String, val duration: Duration): StepTrigger { - override fun format() = "music start: $title" - } - - object MusicEnd: StepTrigger { - override fun format() = "music end" - } - - data class Curtain(val state: State, val whileMoving: Boolean): StepTrigger { - enum class State { - OPEN, - CLOSED - } - - override fun format() = "curtain: $state${if (whileMoving) "(while moving)" else ""}" - } - - data class Light(val state: State, val whileFading: Boolean): StepTrigger { - enum class State { - ON, - OFF - } - - override fun format() = "light: $state${if (whileFading) "(while fading)" else ""}" - } - - data class Custom(val text: String): StepTrigger { - override fun format() = "custom: $text" - } -} \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/Device.kt b/src/main/kotlin/de/moritzruth/theaterdsl/device/Device.kt similarity index 62% rename from src/main/kotlin/de/moritzruth/theaterdsl/Device.kt rename to src/main/kotlin/de/moritzruth/theaterdsl/device/Device.kt index 38e5d89..d011080 100644 --- a/src/main/kotlin/de/moritzruth/theaterdsl/Device.kt +++ b/src/main/kotlin/de/moritzruth/theaterdsl/device/Device.kt @@ -1,9 +1,12 @@ -package de.moritzruth.theaterdsl +package de.moritzruth.theaterdsl.device import de.moritzruth.theaterdsl.dmx.DmxAddress import de.moritzruth.theaterdsl.dmx.DmxDataWriter +import kotlinx.collections.immutable.ImmutableSet interface Device { + val dvs: ImmutableSet> + val firstChannel: DmxAddress val numberOfChannels: UInt fun writeDmxData(writer: DmxDataWriter) diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/DynamicValue.kt b/src/main/kotlin/de/moritzruth/theaterdsl/device/DynamicValue.kt similarity index 87% rename from src/main/kotlin/de/moritzruth/theaterdsl/DynamicValue.kt rename to src/main/kotlin/de/moritzruth/theaterdsl/device/DynamicValue.kt index 8d22c30..b93fbf4 100644 --- a/src/main/kotlin/de/moritzruth/theaterdsl/DynamicValue.kt +++ b/src/main/kotlin/de/moritzruth/theaterdsl/device/DynamicValue.kt @@ -1,4 +1,4 @@ -package de.moritzruth.theaterdsl +package de.moritzruth.theaterdsl.device import de.moritzruth.theaterdsl.value.* import kotlinx.collections.immutable.ImmutableList @@ -13,15 +13,16 @@ import kotlin.time.ExperimentalTime import kotlin.time.TimeSource interface DynamicValue { + fun reset() fun getCurrentValue(): T } @OptIn(ExperimentalTime::class) -class PercentageDV(initialStaticValue: Percentage = 0.percent): DynamicValue { +class PercentageDV(private val initialStaticValue: Percentage = 0.percent) : DynamicValue { private sealed interface State { - data class Static(val value: Percentage): State + data class Static(val value: Percentage) : State - data class Fade(val start: Percentage, val end: Percentage, val duration: Duration): State { + data class Fade(val start: Percentage, val end: Percentage, val duration: Duration) : State { val delta = end.value - start.value } @@ -38,7 +39,7 @@ class PercentageDV(initialStaticValue: Percentage = 0.percent): DynamicValue, val interval: Duration, val startIndex: Int): State + data class Step(val steps: ImmutableList, val interval: Duration, val startIndex: Int) : State } private var stateChangeMark = TimeSource.Monotonic.markNow() @@ -48,6 +49,10 @@ class PercentageDV(initialStaticValue: Percentage = 0.percent): DynamicValue { +class ColorDV(private val initialStaticValue: Color = Color.WHITE) : DynamicValue { private sealed interface State { - data class Static(val value: Color): State - data class Fade(val start: Color, val end: Color, val duration: Duration): State { + data class Static(val value: Color) : State + data class Fade(val start: Color, val end: Color, val duration: Duration) : State { val deltaHue = ((end.hue.degree - start.hue.degree).mod(360f) + 540f).mod(360f) - 180f val deltaSaturation = end.saturation.value - start.saturation.value val deltaBrightness = end.brightness.value - start.brightness.value @@ -114,10 +119,14 @@ class ColorDV(initialStaticValue: Color = Color.WHITE): DynamicValue { stateChangeMark = TimeSource.Monotonic.markNow() } + override fun reset() { + state = State.Static(initialStaticValue) + } + override fun getCurrentValue(): Color { val elapsedTime = stateChangeMark.elapsedNow() - return when(val s = state) { + return when (val s = state) { is State.Static -> s.value is State.Fade -> { val progress = min(elapsedTime / s.duration, 1.0) diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/dmx/EnttecOpenDmxUsb.kt b/src/main/kotlin/de/moritzruth/theaterdsl/dmx/EnttecOpenDmxUsb.kt index 2084a82..8cf9780 100644 --- a/src/main/kotlin/de/moritzruth/theaterdsl/dmx/EnttecOpenDmxUsb.kt +++ b/src/main/kotlin/de/moritzruth/theaterdsl/dmx/EnttecOpenDmxUsb.kt @@ -2,11 +2,14 @@ package de.moritzruth.theaterdsl.dmx import com.fazecast.jSerialComm.SerialPort import com.fazecast.jSerialComm.SerialPortIOException +import io.github.oshai.KotlinLogging import kotlin.concurrent.thread object EnttecOpenDmxUsb { private const val PORT_NAME = "FT232R USB UART" + private val logger = KotlinLogging.logger("EnttecOpenDmxUsb") + var data = UByteArray(512) set(value) { require(value.size <= 512) { "array length <= 512" } @@ -78,11 +81,11 @@ object EnttecOpenDmxUsb { port.numStopBits = SerialPort.TWO_STOP_BITS port.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED) - println("DMX interface connected") + logger.info("DMX interface connected") return true } else { - if (port.lastErrorCode == 13) System.err.println("The serial port could not be opened because of insufficient permissions (13).") - else System.err.println("The serial port could not be opened (${port.lastErrorCode}).") + if (port.lastErrorCode == 13) logger.error("The serial port could not be opened because of insufficient permissions (13).") + else logger.error("The serial port could not be opened (${port.lastErrorCode}).") return false } } diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/show/Act.kt b/src/main/kotlin/de/moritzruth/theaterdsl/show/Act.kt new file mode 100644 index 0000000..371f332 --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/show/Act.kt @@ -0,0 +1,10 @@ +package de.moritzruth.theaterdsl.show + +import kotlinx.serialization.Serializable + +@Serializable +data class Act(val name: String, val scenes: List) + +operator fun List.get(position: ShowPosition) = + if (position == ShowPosition.START) Step.START + else this[position.act].scenes[position.scene].steps[position.step] \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/Dsl.kt b/src/main/kotlin/de/moritzruth/theaterdsl/show/Dsl.kt similarity index 75% rename from src/main/kotlin/de/moritzruth/theaterdsl/Dsl.kt rename to src/main/kotlin/de/moritzruth/theaterdsl/show/Dsl.kt index 85ef9dc..4f627a7 100644 --- a/src/main/kotlin/de/moritzruth/theaterdsl/Dsl.kt +++ b/src/main/kotlin/de/moritzruth/theaterdsl/show/Dsl.kt @@ -1,12 +1,20 @@ -package de.moritzruth.theaterdsl +package de.moritzruth.theaterdsl.show +import de.moritzruth.theaterdsl.runOptionalSanityChecks +import de.moritzruth.theaterdsl.util.StringWithDetails import io.github.oshai.KotlinLogging import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableSet @DslMarker annotation class TheaterDslMarker +@TheaterDslMarker +interface ShowBuilderContext { + fun act(name: String, build: ActBuilderContext.() -> Unit) +} + @TheaterDslMarker interface ActBuilderContext { fun scene(name: String, build: SceneBuilderContext.() -> Unit) @@ -19,16 +27,19 @@ interface SceneBuilderContext { @TheaterDslMarker interface StepDataBuilderContext { - var trigger: StepTrigger + var trigger: StepCue val props: PropsBuilderMap fun actors(build: ActorsBuildContext.() -> Unit) fun onRun(block: StepRunner) } -class PropsBuilderMap(private val backingMap: MutableMap) { - operator fun set(position: PropPosition, prop: StringWithDetails) = backingMap.set(position, prop) - operator fun set(position: PropPosition, prop: String) = backingMap.set(position, StringWithDetails(prop)) +class PropsBuilderMap(private val backingMap: MutableMap) { + operator fun set(position: PropPosition, prop: String?) = backingMap.set(position, prop?.let { StringWithDetails(it) }) + + operator fun invoke(block: (PropsBuilderMap) -> Unit) { + block(this) + } } @TheaterDslMarker @@ -54,9 +65,23 @@ class ActorsBuildContext( } @TheaterDslMarker -fun createAct(name: String, build: ActBuilderContext.() -> Unit): Act { +fun createShow(build: ShowBuilderContext.() -> Unit): Show { + val acts = mutableListOf() + + object : ShowBuilderContext { + override fun act(name: String, build: ActBuilderContext.() -> Unit) { + acts.add(buildAct(acts.size, name, build)) + } + }.build() + + return Show(acts.toList()) +} + +@TheaterDslMarker +private fun buildAct(actIndex: Int, name: String, build: ActBuilderContext.() -> Unit): Act { val scenes = mutableListOf() val actorsOnStage = mutableListOf() + val props = PropPosition.values().associateWith { null }.toMutableMap() object : ActBuilderContext { override fun scene(name: String, build: SceneBuilderContext.() -> Unit) { @@ -64,20 +89,20 @@ fun createAct(name: String, build: ActBuilderContext.() -> Unit): Act { object : SceneBuilderContext { override fun step(build: StepDataBuilderContext.() -> Unit) { - var nullableTrigger: StepTrigger? = null - val propsMap = mutableMapOf() + var nullableTrigger: StepCue? = null + val changedProps = mutableMapOf() val actorEntrances = mutableSetOf() val actorExits = mutableSetOf() var runner: StepRunner? = null object : StepDataBuilderContext { - override var trigger: StepTrigger + override var trigger: StepCue get() = nullableTrigger ?: throw IllegalStateException("trigger was not set yet") set(value) { nullableTrigger = value } - override val props = PropsBuilderMap(propsMap) + override val props = PropsBuilderMap(changedProps) override fun actors(build: ActorsBuildContext.() -> Unit) { ActorsBuildContext(actorEntrances, actorExits).build() @@ -122,12 +147,17 @@ fun createAct(name: String, build: ActBuilderContext.() -> Unit): Act { actorsOnStage.removeAll(actorExitsNames) actorsOnStage.addAll(actorEntrancesNames) + changedProps.forEach { (k, v) -> props[k] = v } + steps.add( Step( + ShowPosition(actIndex, scenes.size, steps.size), trigger, actorEntrances.toImmutableSet(), actorExits.toImmutableSet(), actorsOnStage.toImmutableList(), + props.toImmutableMap(), + changedProps.isNotEmpty(), runner ) ) diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/show/Runner.kt b/src/main/kotlin/de/moritzruth/theaterdsl/show/Runner.kt new file mode 100644 index 0000000..7b5fc60 --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/show/Runner.kt @@ -0,0 +1,230 @@ +package de.moritzruth.theaterdsl.show + +import de.moritzruth.theaterdsl.device.Device +import de.moritzruth.theaterdsl.device.DynamicValue +import de.moritzruth.theaterdsl.dmx.EnttecOpenDmxUsb +import de.moritzruth.theaterdsl.dmx.PerDeviceDmxDataWriter +import de.moritzruth.theaterdsl.util.InstantAsEpochMillisecondsSerializer +import de.moritzruth.theaterdsl.util.mapState +import io.github.oshai.KLogger +import io.github.oshai.KotlinLogging +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.cio.* +import io.ktor.server.engine.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import io.ktor.websocket.* +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.roundToLong +import kotlin.system.measureTimeMillis +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration + +class ShowContext( + val devices: ImmutableSet, + val show: Show, + val logger: KLogger, + val stateFlow: MutableStateFlow, + val outputDataFreeze: AtomicInteger +) { + val stepFlow = stateFlow.mapState { + if (it.position == ShowPosition.START) Step.START + else show.acts[it.position.act].scenes[it.position.scene].steps[it.position.step] + } + + fun goTo(position: ShowPosition) { + val step = show.acts[position] + + stateFlow.update { + when { + step.cue is StepCue.MusicStart -> { + it.copy( + position = position, + activeMusic = ActiveShowMusic( + title = step.cue.title, + duration = step.cue.duration + ), + musicStartTime = Clock.System.now() + ) + } + + step.cue is StepCue.MusicEnd || + step.position == ShowPosition.START -> it.copy(position = position, activeMusic = null) + + else -> it.copy(position = position) + } + } + + if (step.cue is StepCue.MusicStart) { + logger.info("Music started: ${step.cue.title} (${step.cue.duration.inWholeSeconds}s)") + } + } +} + +suspend fun runShow(show: Show, devices: ImmutableSet) = coroutineScope { + val logger = KotlinLogging.logger("runShow") + + val stateFlow = MutableStateFlow( + ShowState( + ShowPosition.START, + "", + null, + Instant.DISTANT_PAST + ) + ) + + val context = ShowContext( + devices, + show, + logger, + stateFlow, + AtomicInteger(0) + ) + + startDataWriting(context) + startStepRunning(context) + startWebsocketServer(context) + + launch { + stateFlow.collect { + logger.info("ShowState changed: $it") + } + } +} + +fun CoroutineScope.startDataWriting(context: ShowContext) = launch { + while (isActive) { + val took = measureTimeMillis { + val data = UByteArray(512) + val writer = PerDeviceDmxDataWriter(data) + + for (device in context.devices) { + writer.reset(0, device.firstChannel, device.numberOfChannels) + device.writeDmxData(writer) + } + + if (context.outputDataFreeze.get() == 0) EnttecOpenDmxUsb.data = data + } + + delay(((1000.0 / 30) - took).roundToLong()) + } +} + +fun CoroutineScope.startStepRunning(context: ShowContext) = launch { + var lastPosition = ShowPosition.START + var lastStepJob: Job? = null + + context.stateFlow.map { it.position }.distinctUntilChanged().collect { position -> + context.outputDataFreeze.incrementAndGet() + + if (position < lastPosition) { + context.devices.forEach { it.dvs.forEach(DynamicValue<*>::reset) } + lastPosition = ShowPosition.START + } + + while (lastPosition != position) { + lastPosition = context.show.getNextValidPosition(lastPosition) ?: break + + val step = context.show.acts[lastPosition] + lastStepJob?.cancelAndJoin() + lastStepJob = launch(SupervisorJob(currentCoroutineContext().job)) { + val runContext = object : StepRunContext, CoroutineScope by this {} + + step.runner?.let { runContext.it() } + } + } + + context.outputDataFreeze.decrementAndGet() + } +} + +@Serializable +data class StateUpdateMessage( + @Serializable(with = InstantAsEpochMillisecondsSerializer::class) + val timestamp: Instant, + val state: ShowState +) + +private fun CoroutineScope.startWebsocketServer(context: ShowContext) = launch(Dispatchers.IO) { + val updateMessageJsonFlow = context.stateFlow.map { state -> + Json.encodeToString( + StateUpdateMessage( + Clock.System.now(), + state + ) + ) + } + + val showJson = Json.encodeToString(context.show) + + embeddedServer(CIO, port = 8000) { + install(WebSockets) { + pingPeriod = 10.seconds.toJavaDuration() + timeout = 15.seconds.toJavaDuration() + maxFrameSize = Long.MAX_VALUE + } + + install(ContentNegotiation) { + json() + } + + routing { + route("/api") { + post("/go") { + val position = call.receive() + context.goTo(position) + call.respond(HttpStatusCode.NoContent) + } + + post("/message") { + val message = call.receiveText() + context.stateFlow.update { it.copy(message = message) } + call.respond(HttpStatusCode.NoContent) + } + + get("/show") { + call.respond(context.show.acts.toMutableList()) + } + + get("/state") { + call.respond(context.stateFlow.value) + } + + val clientsCount = AtomicInteger(0) + + webSocket("/ws") { + var count = clientsCount.incrementAndGet() + context.logger.info("[${call.request.local.remoteHost}] connected ($count)") + + val sendJob = launch { + send(Frame.Text(showJson)) + updateMessageJsonFlow.collect { send(Frame.Text(it)) } + } + + try { + for (frame in incoming) { + context.logger.debug { "[${call.request.local.remoteHost}] frame: $frame" } + } + } finally { + sendJob.cancel() + count = clientsCount.decrementAndGet() + context.logger.info("[${call.request.local.remoteHost}] disconnected ($count)") + } + } + } + } + }.start(wait = true) +} \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/show/Scene.kt b/src/main/kotlin/de/moritzruth/theaterdsl/show/Scene.kt new file mode 100644 index 0000000..8f61056 --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/show/Scene.kt @@ -0,0 +1,6 @@ +package de.moritzruth.theaterdsl.show + +import kotlinx.serialization.Serializable + +@Serializable +class Scene(val name: String, val steps: List) \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/show/Show.kt b/src/main/kotlin/de/moritzruth/theaterdsl/show/Show.kt new file mode 100644 index 0000000..e35200c --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/show/Show.kt @@ -0,0 +1,65 @@ +package de.moritzruth.theaterdsl.show + +import de.moritzruth.theaterdsl.util.DurationAsMillisecondsSerializer +import de.moritzruth.theaterdsl.util.InstantAsEpochMillisecondsSerializer +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable +import kotlin.time.Duration + +@Serializable +data class Show(val acts: List) { + fun getNextValidPosition(position: ShowPosition): ShowPosition? { + if (position == ShowPosition.START) return ShowPosition.FIRST + + var nextStep = position.step + 1 + var nextScene = position.scene + var nextAct = position.act + + if (nextStep >= acts[position.act].scenes[position.scene].steps.size) { + nextStep = 0 + nextScene++ + + if (nextScene >= acts[position.act].scenes.size) { + nextScene = 0 + nextAct++ + + if (nextAct >= acts.size) { + return null + } + } + } + + return ShowPosition(nextAct, nextScene, nextStep) + } +} + +@Serializable +data class ShowPosition(val act: Int, val scene: Int, val step: Int) : Comparable { + companion object { + val START = ShowPosition(-1, 0, 0) + val FIRST = ShowPosition(0, 0, 0) + } + + override fun compareTo(other: ShowPosition): Int { + return if (act == other.act) { + if (scene == other.scene) step.compareTo(other.step) + else scene.compareTo(other.scene) + } else act.compareTo(other.act) + } +} + +@Serializable +data class ShowState( + val position: ShowPosition, + val message: String, + val activeMusic: ActiveShowMusic?, + @Serializable(with = InstantAsEpochMillisecondsSerializer::class) + val musicStartTime: Instant +) + +@Serializable +data class ActiveShowMusic( + val title: String, + @Serializable(with = DurationAsMillisecondsSerializer::class) + val duration: Duration +) diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/show/Step.kt b/src/main/kotlin/de/moritzruth/theaterdsl/show/Step.kt new file mode 100644 index 0000000..e839a78 --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/show/Step.kt @@ -0,0 +1,48 @@ +package de.moritzruth.theaterdsl.show + +import de.moritzruth.theaterdsl.util.StringWithDetails +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +enum class PropPosition { + PROSCENIUM_LEFT, + PROSCENIUM_CENTER, + PROSCENIUM_RIGHT, + LEFT, + CENTER, + RIGHT, + BACKDROP +} + +@TheaterDslMarker +interface StepRunContext: CoroutineScope + +typealias StepRunner = (StepRunContext.() -> Unit) + +@Serializable +class Step( + val position: ShowPosition, + val cue: StepCue, + val actorEntrances: Set, + val actorExits: Set, + val actorsOnStage: List, + val props: Map, + val hasChangedProps: Boolean, + @Transient + val runner: StepRunner? = null +) { + companion object { + val START = Step( + ShowPosition.START, + StepCue.Custom("Start"), + emptySet(), + emptySet(), + emptyList(), + emptyMap(), + false, + null + ) + } +} diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/show/StepCue.kt b/src/main/kotlin/de/moritzruth/theaterdsl/show/StepCue.kt new file mode 100644 index 0000000..449598d --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/show/StepCue.kt @@ -0,0 +1,63 @@ +package de.moritzruth.theaterdsl.show + +import de.moritzruth.theaterdsl.util.DurationAsMillisecondsSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Duration + +@Serializable +sealed interface StepCue { + fun format(): String + + @Serializable + @SerialName("TEXT") + data class Text(val speaker: String, val text: String, val clarification: String? = null) : StepCue { + override fun format() = "text: $speaker: $text" + } + + @Serializable + @SerialName("MUSIC_START") + data class MusicStart(val title: String, val duration: @Serializable(with = DurationAsMillisecondsSerializer::class) Duration) : StepCue { + override fun format() = "music start: $title" + } + + @Serializable + @SerialName("MUSIC_END") + object MusicEnd : StepCue { + override fun format() = "music end" + } + + @Serializable + @SerialName("CURTAIN") + data class Curtain(val state: State, val whileMoving: Boolean) : StepCue { + enum class State { + @SerialName("open") + OPEN, + + @SerialName("closed") + CLOSED + } + + override fun format() = "curtain: $state${if (whileMoving) "(while moving)" else ""}" + } + + @Serializable + @SerialName("LIGHT") + data class Light(val state: State, val whileFading: Boolean) : StepCue { + enum class State { + @SerialName("on") + ON, + + @SerialName("off") + OFF + } + + override fun format() = "light: $state${if (whileFading) "(while fading)" else ""}" + } + + @Serializable + @SerialName("CUSTOM") + data class Custom(val text: String) : StepCue { + override fun format() = "custom: $text" + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/util/DerivedStateFlow.kt b/src/main/kotlin/de/moritzruth/theaterdsl/util/DerivedStateFlow.kt new file mode 100644 index 0000000..28990fb --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/util/DerivedStateFlow.kt @@ -0,0 +1,37 @@ +package de.moritzruth.theaterdsl.util + +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* + +// from https://github.com/Kotlin/kotlinx.coroutines/issues/2631#issuecomment-870565860 + +class DerivedStateFlow( + private val getValue: () -> T, + private val flow: Flow +) : StateFlow { + override val replayCache: List + get() = listOf(value) + + override val value: T + get() = getValue() + + @InternalCoroutinesApi + override suspend fun collect(collector: FlowCollector): Nothing { + coroutineScope { flow.distinctUntilChanged().stateIn(this).collect(collector) } + } +} + +fun StateFlow.mapState(transform: (a: T1) -> R): StateFlow { + return DerivedStateFlow( + getValue = { transform(this.value) }, + flow = this.map { a -> transform(a) } + ) +} + +fun combineStates(flow: StateFlow, flow2: StateFlow, transform: (a: T1, b: T2) -> R): StateFlow { + return DerivedStateFlow( + getValue = { transform(flow.value, flow2.value) }, + flow = combine(flow, flow2) { a, b -> transform(a, b) } + ) +} \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/util/DurationAsMillisecondsSerializer.kt b/src/main/kotlin/de/moritzruth/theaterdsl/util/DurationAsMillisecondsSerializer.kt new file mode 100644 index 0000000..c3e42be --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/util/DurationAsMillisecondsSerializer.kt @@ -0,0 +1,20 @@ +package de.moritzruth.theaterdsl.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +object DurationAsMillisecondsSerializer : KSerializer { + override val descriptor: SerialDescriptor = serializer().descriptor + + override fun deserialize(decoder: Decoder): Duration = + decoder.decodeLong().milliseconds + + override fun serialize(encoder: Encoder, value: Duration) { + encoder.encodeLong(value.inWholeMilliseconds) + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/util/InstantAsEpochMillisecondsSerializer.kt b/src/main/kotlin/de/moritzruth/theaterdsl/util/InstantAsEpochMillisecondsSerializer.kt new file mode 100644 index 0000000..3f9b060 --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/util/InstantAsEpochMillisecondsSerializer.kt @@ -0,0 +1,19 @@ +package de.moritzruth.theaterdsl.util + +import kotlinx.datetime.Instant +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer + +object InstantAsEpochMillisecondsSerializer : KSerializer { + override val descriptor: SerialDescriptor = serializer().descriptor + + override fun deserialize(decoder: Decoder): Instant = + Instant.fromEpochMilliseconds(decoder.decodeLong()) + + override fun serialize(encoder: Encoder, value: Instant) { + encoder.encodeLong(value.toEpochMilliseconds()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/moritzruth/theaterdsl/util/StringWithDetails.kt b/src/main/kotlin/de/moritzruth/theaterdsl/util/StringWithDetails.kt new file mode 100644 index 0000000..94ca245 --- /dev/null +++ b/src/main/kotlin/de/moritzruth/theaterdsl/util/StringWithDetails.kt @@ -0,0 +1,18 @@ +package de.moritzruth.theaterdsl.util + +import kotlinx.serialization.Serializable + +/** + * A string which has optional additional details separated by " / " + */ +@JvmInline +@Serializable +value class StringWithDetails(val value: String) { + companion object { + const val DELIMITER = " / " + } + + val main get() = value.split(DELIMITER)[0] + val details: String? get() = value.split(DELIMITER, limit = 2).takeIf { it.size == 2 }?.let { it[1] } + val hasDetails get() = details !== null +} \ No newline at end of file diff --git a/src/main/resources/simplelogger.properties b/src/main/resources/simplelogger.properties new file mode 100644 index 0000000..eafa2b0 --- /dev/null +++ b/src/main/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.defaultLogLevel=debug \ No newline at end of file diff --git a/versions.properties b/versions.properties index c08f3f6..9bca937 100644 --- a/versions.properties +++ b/versions.properties @@ -12,13 +12,9 @@ version.com.fazecast..jSerialComm=2.9.3 version.io.github.oshai..kotlin-logging-jvm=4.0.0-beta-28 version.kotlin=1.8.20 - version.kotlinx.collections.immutable=0.3.5 - version.kotlinx.coroutines=1.7.1 - version.kotlinx.datetime=0.4.0 - version.kotlinx.serialization=1.5.1 - +version.ktor=2.3.0 version.org.slf4j..slf4j-simple=2.0.7