This commit is contained in:
Moritz Ruth 2023-05-23 00:25:19 +02:00
parent c4b134bb80
commit ea15c19e70
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
26 changed files with 653 additions and 215 deletions

View file

@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
kotlin("jvm") kotlin("jvm")
kotlin("plugin.serialization")
application application
} }
@ -23,6 +24,11 @@ dependencies {
implementation(KotlinX.collections.immutable) implementation(KotlinX.collections.immutable)
implementation(KotlinX.serialization.json) implementation(KotlinX.serialization.json)
implementation(KotlinX.datetime) 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("org.slf4j:slf4j-simple:_")
implementation("com.fazecast:jSerialComm:_") implementation("com.fazecast:jSerialComm:_")
implementation("io.github.oshai:kotlin-logging-jvm:_") implementation("io.github.oshai:kotlin-logging-jvm:_")

View file

@ -1,50 +1,23 @@
package de.moritzruth.lampenfieber package de.moritzruth.lampenfieber
import de.moritzruth.theaterdsl.StepTrigger import de.moritzruth.lampenfieber.device.StairvilleTriLedBar
import de.moritzruth.theaterdsl.createAct
import de.moritzruth.theaterdsl.dmx.DmxAddress import de.moritzruth.theaterdsl.dmx.DmxAddress
import de.moritzruth.theaterdsl.dmx.EnttecOpenDmxUsb import de.moritzruth.theaterdsl.dmx.EnttecOpenDmxUsb
import de.moritzruth.theaterdsl.runShow import de.moritzruth.theaterdsl.show.createShow
import de.moritzruth.theaterdsl.value.Color import de.moritzruth.theaterdsl.show.runShow
import de.moritzruth.theaterdsl.value.degrees import kotlinx.collections.immutable.persistentSetOf
import de.moritzruth.theaterdsl.value.percent
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
val bar = FloorBarDevice(DmxAddress(15u)) val bar = StairvilleTriLedBar(DmxAddress(15u))
val firstAct = createAct("Erster Akt") { val show = createShow {
act("Erster Akt") {
scene("Prolog") { scene("Prolog") {
step {
trigger = StepTrigger.MusicStart("1. Ouvertüre: Dissonanzen in Schwarz und Weiß", 2.minutes)
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() { suspend fun main() {
EnttecOpenDmxUsb.start() EnttecOpenDmxUsb.start()
runShow( runShow(show, persistentSetOf(bar))
devices = setOf(
bar
),
acts = listOf(
firstAct
)
)
} }

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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.DmxAddress
import de.moritzruth.theaterdsl.dmx.DmxDataWriter 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 val numberOfChannels: UInt = 5u
override fun writeDmxData(writer: DmxDataWriter) { override fun writeDmxData(writer: DmxDataWriter) {
@ -19,4 +22,10 @@ class FloorBarDevice(override val firstChannel: DmxAddress): Device {
val color = ColorDV() val color = ColorDV()
val brightness = PercentageDV() val brightness = PercentageDV()
val strobeSpeed = PercentageDV() val strobeSpeed = PercentageDV()
override val dvs = persistentSetOf(
color,
brightness,
strobeSpeed
)
} }

View file

@ -1,5 +0,0 @@
package de.moritzruth.theaterdsl
import kotlinx.collections.immutable.ImmutableList
class Act(val name: String, val scenes: ImmutableList<Scene>)

View file

@ -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<Device>, acts: List<Act>) = 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)
}

View file

@ -1,5 +0,0 @@
package de.moritzruth.theaterdsl
import kotlinx.collections.immutable.ImmutableList
class Scene(val name: String, val steps: ImmutableList<Step>)

View file

@ -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<StringWithDetails>,
val actorExits: ImmutableSet<StringWithDetails>,
val actorsOnStage: ImmutableList<String>,
val runner: StepRunner?
)

View file

@ -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"
}
}

View file

@ -1,9 +1,12 @@
package de.moritzruth.theaterdsl package de.moritzruth.theaterdsl.device
import de.moritzruth.theaterdsl.dmx.DmxAddress import de.moritzruth.theaterdsl.dmx.DmxAddress
import de.moritzruth.theaterdsl.dmx.DmxDataWriter import de.moritzruth.theaterdsl.dmx.DmxDataWriter
import kotlinx.collections.immutable.ImmutableSet
interface Device { interface Device {
val dvs: ImmutableSet<DynamicValue<*>>
val firstChannel: DmxAddress val firstChannel: DmxAddress
val numberOfChannels: UInt val numberOfChannels: UInt
fun writeDmxData(writer: DmxDataWriter) fun writeDmxData(writer: DmxDataWriter)

View file

@ -1,4 +1,4 @@
package de.moritzruth.theaterdsl package de.moritzruth.theaterdsl.device
import de.moritzruth.theaterdsl.value.* import de.moritzruth.theaterdsl.value.*
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -13,11 +13,12 @@ import kotlin.time.ExperimentalTime
import kotlin.time.TimeSource import kotlin.time.TimeSource
interface DynamicValue<T> { interface DynamicValue<T> {
fun reset()
fun getCurrentValue(): T fun getCurrentValue(): T
} }
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
class PercentageDV(initialStaticValue: Percentage = 0.percent): DynamicValue<Percentage> { class PercentageDV(private val initialStaticValue: Percentage = 0.percent) : DynamicValue<Percentage> {
private sealed interface State { private sealed interface State {
data class Static(val value: Percentage) : State data class Static(val value: Percentage) : State
@ -48,6 +49,10 @@ class PercentageDV(initialStaticValue: Percentage = 0.percent): DynamicValue<Per
stateChangeMark = TimeSource.Monotonic.markNow() stateChangeMark = TimeSource.Monotonic.markNow()
} }
override fun reset() {
state = State.Static(initialStaticValue)
}
override fun getCurrentValue(): Percentage { override fun getCurrentValue(): Percentage {
val elapsedTime = stateChangeMark.elapsedNow() val elapsedTime = stateChangeMark.elapsedNow()
@ -97,7 +102,7 @@ class PercentageDV(initialStaticValue: Percentage = 0.percent): DynamicValue<Per
} }
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
class ColorDV(initialStaticValue: Color = Color.WHITE): DynamicValue<Color> { class ColorDV(private val initialStaticValue: Color = Color.WHITE) : DynamicValue<Color> {
private sealed interface State { private sealed interface State {
data class Static(val value: Color) : State data class Static(val value: Color) : State
data class Fade(val start: Color, val end: Color, val duration: Duration) : State { data class Fade(val start: Color, val end: Color, val duration: Duration) : State {
@ -114,6 +119,10 @@ class ColorDV(initialStaticValue: Color = Color.WHITE): DynamicValue<Color> {
stateChangeMark = TimeSource.Monotonic.markNow() stateChangeMark = TimeSource.Monotonic.markNow()
} }
override fun reset() {
state = State.Static(initialStaticValue)
}
override fun getCurrentValue(): Color { override fun getCurrentValue(): Color {
val elapsedTime = stateChangeMark.elapsedNow() val elapsedTime = stateChangeMark.elapsedNow()

View file

@ -2,11 +2,14 @@ package de.moritzruth.theaterdsl.dmx
import com.fazecast.jSerialComm.SerialPort import com.fazecast.jSerialComm.SerialPort
import com.fazecast.jSerialComm.SerialPortIOException import com.fazecast.jSerialComm.SerialPortIOException
import io.github.oshai.KotlinLogging
import kotlin.concurrent.thread import kotlin.concurrent.thread
object EnttecOpenDmxUsb { object EnttecOpenDmxUsb {
private const val PORT_NAME = "FT232R USB UART" private const val PORT_NAME = "FT232R USB UART"
private val logger = KotlinLogging.logger("EnttecOpenDmxUsb")
var data = UByteArray(512) var data = UByteArray(512)
set(value) { set(value) {
require(value.size <= 512) { "array length <= 512" } require(value.size <= 512) { "array length <= 512" }
@ -78,11 +81,11 @@ object EnttecOpenDmxUsb {
port.numStopBits = SerialPort.TWO_STOP_BITS port.numStopBits = SerialPort.TWO_STOP_BITS
port.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED) port.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED)
println("DMX interface connected") logger.info("DMX interface connected")
return true return true
} else { } else {
if (port.lastErrorCode == 13) System.err.println("The serial port could not be opened because of insufficient permissions (13).") if (port.lastErrorCode == 13) logger.error("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}).") else logger.error("The serial port could not be opened (${port.lastErrorCode}).")
return false return false
} }
} }

View file

@ -0,0 +1,10 @@
package de.moritzruth.theaterdsl.show
import kotlinx.serialization.Serializable
@Serializable
data class Act(val name: String, val scenes: List<Scene>)
operator fun List<Act>.get(position: ShowPosition) =
if (position == ShowPosition.START) Step.START
else this[position.act].scenes[position.scene].steps[position.step]

View file

@ -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 io.github.oshai.KotlinLogging
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
@DslMarker @DslMarker
annotation class TheaterDslMarker annotation class TheaterDslMarker
@TheaterDslMarker
interface ShowBuilderContext {
fun act(name: String, build: ActBuilderContext.() -> Unit)
}
@TheaterDslMarker @TheaterDslMarker
interface ActBuilderContext { interface ActBuilderContext {
fun scene(name: String, build: SceneBuilderContext.() -> Unit) fun scene(name: String, build: SceneBuilderContext.() -> Unit)
@ -19,16 +27,19 @@ interface SceneBuilderContext {
@TheaterDslMarker @TheaterDslMarker
interface StepDataBuilderContext { interface StepDataBuilderContext {
var trigger: StepTrigger var trigger: StepCue
val props: PropsBuilderMap val props: PropsBuilderMap
fun actors(build: ActorsBuildContext.() -> Unit) fun actors(build: ActorsBuildContext.() -> Unit)
fun onRun(block: StepRunner) fun onRun(block: StepRunner)
} }
class PropsBuilderMap(private val backingMap: MutableMap<PropPosition, StringWithDetails>) { class PropsBuilderMap(private val backingMap: MutableMap<PropPosition, StringWithDetails?>) {
operator fun set(position: PropPosition, prop: StringWithDetails) = backingMap.set(position, prop) operator fun set(position: PropPosition, prop: String?) = backingMap.set(position, prop?.let { StringWithDetails(it) })
operator fun set(position: PropPosition, prop: String) = backingMap.set(position, StringWithDetails(prop))
operator fun invoke(block: (PropsBuilderMap) -> Unit) {
block(this)
}
} }
@TheaterDslMarker @TheaterDslMarker
@ -54,9 +65,23 @@ class ActorsBuildContext(
} }
@TheaterDslMarker @TheaterDslMarker
fun createAct(name: String, build: ActBuilderContext.() -> Unit): Act { fun createShow(build: ShowBuilderContext.() -> Unit): Show {
val acts = mutableListOf<Act>()
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<Scene>() val scenes = mutableListOf<Scene>()
val actorsOnStage = mutableListOf<String>() val actorsOnStage = mutableListOf<String>()
val props = PropPosition.values().associateWith<PropPosition, StringWithDetails?> { null }.toMutableMap()
object : ActBuilderContext { object : ActBuilderContext {
override fun scene(name: String, build: SceneBuilderContext.() -> Unit) { override fun scene(name: String, build: SceneBuilderContext.() -> Unit) {
@ -64,20 +89,20 @@ fun createAct(name: String, build: ActBuilderContext.() -> Unit): Act {
object : SceneBuilderContext { object : SceneBuilderContext {
override fun step(build: StepDataBuilderContext.() -> Unit) { override fun step(build: StepDataBuilderContext.() -> Unit) {
var nullableTrigger: StepTrigger? = null var nullableTrigger: StepCue? = null
val propsMap = mutableMapOf<PropPosition, StringWithDetails>() val changedProps = mutableMapOf<PropPosition, StringWithDetails?>()
val actorEntrances = mutableSetOf<StringWithDetails>() val actorEntrances = mutableSetOf<StringWithDetails>()
val actorExits = mutableSetOf<StringWithDetails>() val actorExits = mutableSetOf<StringWithDetails>()
var runner: StepRunner? = null var runner: StepRunner? = null
object : StepDataBuilderContext { object : StepDataBuilderContext {
override var trigger: StepTrigger override var trigger: StepCue
get() = nullableTrigger ?: throw IllegalStateException("trigger was not set yet") get() = nullableTrigger ?: throw IllegalStateException("trigger was not set yet")
set(value) { set(value) {
nullableTrigger = value nullableTrigger = value
} }
override val props = PropsBuilderMap(propsMap) override val props = PropsBuilderMap(changedProps)
override fun actors(build: ActorsBuildContext.() -> Unit) { override fun actors(build: ActorsBuildContext.() -> Unit) {
ActorsBuildContext(actorEntrances, actorExits).build() ActorsBuildContext(actorEntrances, actorExits).build()
@ -122,12 +147,17 @@ fun createAct(name: String, build: ActBuilderContext.() -> Unit): Act {
actorsOnStage.removeAll(actorExitsNames) actorsOnStage.removeAll(actorExitsNames)
actorsOnStage.addAll(actorEntrancesNames) actorsOnStage.addAll(actorEntrancesNames)
changedProps.forEach { (k, v) -> props[k] = v }
steps.add( steps.add(
Step( Step(
ShowPosition(actIndex, scenes.size, steps.size),
trigger, trigger,
actorEntrances.toImmutableSet(), actorEntrances.toImmutableSet(),
actorExits.toImmutableSet(), actorExits.toImmutableSet(),
actorsOnStage.toImmutableList(), actorsOnStage.toImmutableList(),
props.toImmutableMap(),
changedProps.isNotEmpty(),
runner runner
) )
) )

View file

@ -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<Device>,
val show: Show,
val logger: KLogger,
val stateFlow: MutableStateFlow<ShowState>,
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<Device>) = 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<ShowPosition>()
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)
}

View file

@ -0,0 +1,6 @@
package de.moritzruth.theaterdsl.show
import kotlinx.serialization.Serializable
@Serializable
class Scene(val name: String, val steps: List<Step>)

View file

@ -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<Act>) {
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<ShowPosition> {
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
)

View file

@ -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<StringWithDetails>,
val actorExits: Set<StringWithDetails>,
val actorsOnStage: List<String>,
val props: Map<PropPosition, StringWithDetails?>,
val hasChangedProps: Boolean,
@Transient
val runner: StepRunner? = null
) {
companion object {
val START = Step(
ShowPosition.START,
StepCue.Custom("Start"),
emptySet(),
emptySet(),
emptyList(),
emptyMap(),
false,
null
)
}
}

View file

@ -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"
}
}

View file

@ -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<T>(
private val getValue: () -> T,
private val flow: Flow<T>
) : StateFlow<T> {
override val replayCache: List<T>
get() = listOf(value)
override val value: T
get() = getValue()
@InternalCoroutinesApi
override suspend fun collect(collector: FlowCollector<T>): Nothing {
coroutineScope { flow.distinctUntilChanged().stateIn(this).collect(collector) }
}
}
fun <T1, R> StateFlow<T1>.mapState(transform: (a: T1) -> R): StateFlow<R> {
return DerivedStateFlow(
getValue = { transform(this.value) },
flow = this.map { a -> transform(a) }
)
}
fun <T1, T2, R> combineStates(flow: StateFlow<T1>, flow2: StateFlow<T2>, transform: (a: T1, b: T2) -> R): StateFlow<R> {
return DerivedStateFlow(
getValue = { transform(flow.value, flow2.value) },
flow = combine(flow, flow2) { a, b -> transform(a, b) }
)
}

View file

@ -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<Duration> {
override val descriptor: SerialDescriptor = serializer<Duration>().descriptor
override fun deserialize(decoder: Decoder): Duration =
decoder.decodeLong().milliseconds
override fun serialize(encoder: Encoder, value: Duration) {
encoder.encodeLong(value.inWholeMilliseconds)
}
}

View file

@ -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<Instant> {
override val descriptor: SerialDescriptor = serializer<Instant>().descriptor
override fun deserialize(decoder: Decoder): Instant =
Instant.fromEpochMilliseconds(decoder.decodeLong())
override fun serialize(encoder: Encoder, value: Instant) {
encoder.encodeLong(value.toEpochMilliseconds())
}
}

View file

@ -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
}

View file

@ -0,0 +1 @@
org.slf4j.simpleLogger.defaultLogLevel=debug

View file

@ -12,13 +12,9 @@ version.com.fazecast..jSerialComm=2.9.3
version.io.github.oshai..kotlin-logging-jvm=4.0.0-beta-28 version.io.github.oshai..kotlin-logging-jvm=4.0.0-beta-28
version.kotlin=1.8.20 version.kotlin=1.8.20
version.kotlinx.collections.immutable=0.3.5 version.kotlinx.collections.immutable=0.3.5
version.kotlinx.coroutines=1.7.1 version.kotlinx.coroutines=1.7.1
version.kotlinx.datetime=0.4.0 version.kotlinx.datetime=0.4.0
version.kotlinx.serialization=1.5.1 version.kotlinx.serialization=1.5.1
version.ktor=2.3.0
version.org.slf4j..slf4j-simple=2.0.7 version.org.slf4j..slf4j-simple=2.0.7