From 976328ed69291db7f618ff0c63425ac04189e549 Mon Sep 17 00:00:00 2001 From: rnentjes Date: Sun, 30 Jun 2024 20:32:43 +0200 Subject: [PATCH] Save patch --- .gitignore | 2 +- .../nl/astraeus/vst/chip/ChipProcessor.kt | 121 +++++++------ build.gradle.kts | 6 + .../kotlin/nl/astraeus/vst/chip/PatchDTO.kt | 32 ++++ .../nl/astraeus/vst/chip/logger/Logger.kt | 53 ++++++ .../kotlin/nl/astraeus/vst/chip/Main.kt | 21 +-- .../astraeus/vst/chip/audio/VstChipWorklet.kt | 79 +++++++- .../kotlin/nl/astraeus/vst/chip/midi/Midi.kt | 7 + .../nl/astraeus/vst/chip/view/MainView.kt | 139 +++++++------- .../astraeus/vst/chip/ws/WebsocketClient.kt | 120 +++++++++++++ .../kotlin/nl/astraeus/vst/chip/GenerateId.kt | 16 ++ .../kotlin/nl/astraeus/vst/chip/Index.kt | 2 - .../kotlin/nl/astraeus/vst/chip/Main.kt | 31 +++- .../nl/astraeus/vst/chip/RequestHandler.kt | 15 -- .../kotlin/nl/astraeus/vst/chip/db/BaseDao.kt | 170 ++++++++++++++++++ .../nl/astraeus/vst/chip/db/Database.kt | 99 ++++++++++ .../kotlin/nl/astraeus/vst/chip/db/Entity.kt | 16 ++ .../nl/astraeus/vst/chip/db/Migrations.kt | 106 +++++++++++ .../nl/astraeus/vst/chip/db/PatchDao.kt | 31 ++++ .../nl/astraeus/vst/chip/db/PatchEntity.kt | 12 ++ .../vst/chip/db/PatchEntityQueryProvider.kt | 64 +++++++ .../kotlin/nl/astraeus/vst/chip/web/Index.kt | 42 +++++ .../astraeus/vst/chip/web/RequestHandler.kt | 128 +++++++++++++ .../nl/astraeus/vst/chip/web/Session.kt | 5 + 24 files changed, 1155 insertions(+), 162 deletions(-) create mode 100644 src/commonMain/kotlin/nl/astraeus/vst/chip/PatchDTO.kt create mode 100644 src/commonMain/kotlin/nl/astraeus/vst/chip/logger/Logger.kt create mode 100644 src/jsMain/kotlin/nl/astraeus/vst/chip/ws/WebsocketClient.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/GenerateId.kt delete mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/Index.kt delete mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/RequestHandler.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/db/BaseDao.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Database.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Entity.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Migrations.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchDao.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchEntity.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchEntityQueryProvider.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/web/Index.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/web/RequestHandler.kt create mode 100644 src/jvmMain/kotlin/nl/astraeus/vst/chip/web/Session.kt diff --git a/.gitignore b/.gitignore index 70e57bd..bda6eff 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,7 @@ bin/ ### Mac OS ### .DS_Store -web +/web .kotlin .idea diff --git a/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/chip/ChipProcessor.kt b/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/chip/ChipProcessor.kt index 4344262..919b1ba 100644 --- a/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/chip/ChipProcessor.kt +++ b/audio-worklet/src/jsMain/kotlin/nl/astraeus/vst/chip/ChipProcessor.kt @@ -2,8 +2,10 @@ package nl.astraeus.vst.chip +import nl.astraeus.vst.ADSR import nl.astraeus.vst.AudioWorkletProcessor import nl.astraeus.vst.Note +import nl.astraeus.vst.currentTime import nl.astraeus.vst.registerProcessor import nl.astraeus.vst.sampleRate import org.khronos.webgl.Float32Array @@ -19,14 +21,6 @@ import kotlin.math.sin val POLYPHONICS = 10 val PI2 = PI * 2 -@ExperimentalJsExport -@JsExport -enum class NoteState { - ON, - RELEASED, - OFF -} - @ExperimentalJsExport @JsExport class PlayingNote( @@ -35,17 +29,15 @@ class PlayingNote( ) { fun retrigger(velocity: Int) { this.velocity = velocity - state = NoteState.ON sample = 0 - attackSamples = 2500 - releaseSamples = 10000 + noteStart = currentTime + noteRelease = null } - var state = NoteState.OFF + var noteStart = currentTime + var noteRelease: Double? = null var cycleOffset = 0.0 var sample = 0 - var attackSamples = 2500 - var releaseSamples = 10000 var actualVolume = 0f } @@ -68,11 +60,8 @@ enum class RecordingState { @JsExport class VstChipProcessor : AudioWorkletProcessor() { var midiChannel = 0 - val notes = Array(POLYPHONICS) { - PlayingNote( - 0 - ) - } + val notes = Array(POLYPHONICS) { null } + var waveform = Waveform.SINE.ordinal var volume = 0.75f var dutyCycle = 0.5 @@ -80,6 +69,12 @@ class VstChipProcessor : AudioWorkletProcessor() { var fmAmp = 0.0 var amFreq = 0.0 var amAmp = 0.0 + + var attack = 0.1 + var decay = 0.2 + var sustain = 0.5 + var release = 0.2 + val sampleLength = 1 / sampleRate.toDouble() val recordingBuffer = Float32Array(sampleRate / 60) @@ -198,27 +193,49 @@ class VstChipProcessor : AudioWorkletProcessor() { val value = bytes[2] when (knob) { - 0x46 -> { + 7 -> { volume = value / 127f } - 0x4a -> { - dutyCycle = value / 127.0 - } - - 0x4b -> { - fmFreq = value / 127.0 - } - - 0x4c -> { - fmAmp = value / 127.0 - } 0x47 -> { + dutyCycle = value / 127.0 + } + + 0x4a -> { + fmFreq = value / 127.0 + } + + 0x4b -> { + fmAmp = value / 127.0 + } + + 0x4c -> { amFreq = value / 127.0 } + 0x4d -> { + amAmp = value / 127.0 + } + + 0x49 -> { + attack = value / 127.0 + } + + 0x4b -> { + decay = value / 127.0 + } + + 0x46 -> { + sustain = value / 127.0 + } 0x48 -> { - amAmp = value / 127.0 + release = value / 127.0 + } + + 123 -> { + for (note in notes) { + note?.noteRelease = currentTime + } } } } @@ -238,21 +255,17 @@ class VstChipProcessor : AudioWorkletProcessor() { private fun noteOn(note: Int, velocity: Int) { for (i in 0 until POLYPHONICS) { - if (notes[i].note == note) { - notes[i].retrigger(velocity) + if (notes[i]?.note == note) { + notes[i]?.retrigger(velocity) return } } for (i in 0 until POLYPHONICS) { - if (notes[i].state == NoteState.OFF) { + if (notes[i] == null) { notes[i] = PlayingNote( note, velocity ) - notes[i].state = NoteState.ON - - val n = Note.fromMidi(note) - //console.log("Playing note: ${n.sharp} (${n.freq})") break } } @@ -260,8 +273,8 @@ class VstChipProcessor : AudioWorkletProcessor() { private fun noteOff(note: Int) { for (i in 0 until POLYPHONICS) { - if (notes[i].note == note && notes[i].state == NoteState.ON) { - notes[i].state = NoteState.RELEASED + if (notes[i]?.note == note) { + notes[i]?.noteRelease = currentTime break } } @@ -279,7 +292,7 @@ class VstChipProcessor : AudioWorkletProcessor() { var lowestNote = 200 for (note in notes) { - if (note.state != NoteState.OFF) { + if (note != null) { lowestNote = min(lowestNote, note.note) } } @@ -289,23 +302,25 @@ class VstChipProcessor : AudioWorkletProcessor() { recordingStart = 0 } - for (note in notes) { - if (note.state != NoteState.OFF) { + for ((index, note) in notes.withIndex()) { + if (note != null) { val sampleDelta = Note.fromMidi(note.note).sampleDelta for (i in 0 until samples) { var targetVolume = note.velocity / 127f - if (note.state == NoteState.ON && note.sample < note.attackSamples) { - note.attackSamples-- - targetVolume *= ( 1f - (note.attackSamples / 2500f)) - } else if (note.state == NoteState.RELEASED) { - note.releaseSamples-- - targetVolume *= (note.releaseSamples / 10000f) - } + targetVolume *= ADSR.calculate( + attack, + decay, + sustain, + release, + note.noteStart, + currentTime, + note.noteRelease + ).toFloat() note.actualVolume += (targetVolume - note.actualVolume) * 0.0001f - if (note.state == NoteState.RELEASED && note.actualVolume <= 0) { - note.state = NoteState.OFF + if (note.noteRelease != null && note.actualVolume <= 0.01) { + notes[index] = null } var cycleOffset = note.cycleOffset diff --git a/build.gradle.kts b/build.gradle.kts index 7ff896b..f2d74f9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { implementation(project(":common")) //base api("nl.astraeus:kotlin-css-generator:1.0.7") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") } } val jsMain by getting { @@ -57,8 +58,13 @@ kotlin { //base implementation("io.undertow:undertow-core:2.3.14.Final") + implementation("io.undertow:undertow-websockets-jsr:2.3.14.Final") implementation("org.jboss.xnio:xnio-nio:3.8.16.Final") + implementation("org.xerial:sqlite-jdbc:3.46.0.0") + implementation("com.zaxxer:HikariCP:4.0.3") + implementation("nl.astraeus:simple-jdbc-stats:1.6.1") + implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0") } } diff --git a/src/commonMain/kotlin/nl/astraeus/vst/chip/PatchDTO.kt b/src/commonMain/kotlin/nl/astraeus/vst/chip/PatchDTO.kt new file mode 100644 index 0000000..ce193b1 --- /dev/null +++ b/src/commonMain/kotlin/nl/astraeus/vst/chip/PatchDTO.kt @@ -0,0 +1,32 @@ +package nl.astraeus.vst.chip + +import kotlin.js.JsName + +data class PatchDTO( + @JsName("waveform") + val waveform: Int = 0, + @JsName("midiId") + val midiId: String = "", + @JsName("midiChannel") + var midiChannel: Int = 0, + @JsName("volume") + var volume: Double = 0.75, + @JsName("dutyCycle") + var dutyCycle: Double = 0.5, + @JsName("fmModFreq") + var fmModFreq: Double = 0.0, + @JsName("fmModAmp") + var fmModAmp: Double = 0.0, + @JsName("amModFreq") + var amModFreq: Double = 0.0, + @JsName("amModAmp") + var amModAmp: Double = 0.0, + @JsName("attack") + var attack: Double = 0.1, + @JsName("decay") + var decay: Double = 0.2, + @JsName("sustain") + var sustain: Double = 0.5, + @JsName("release") + var release: Double = 0.2, +) diff --git a/src/commonMain/kotlin/nl/astraeus/vst/chip/logger/Logger.kt b/src/commonMain/kotlin/nl/astraeus/vst/chip/logger/Logger.kt new file mode 100644 index 0000000..cd71970 --- /dev/null +++ b/src/commonMain/kotlin/nl/astraeus/vst/chip/logger/Logger.kt @@ -0,0 +1,53 @@ +package nl.astraeus.vst.chip.logger + +val log = Logger + +enum class LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR, + FATAL +} + +object Logger { + var level: LogLevel = LogLevel.INFO + + fun trace(message: () -> String?) { + if (level.ordinal <= LogLevel.TRACE.ordinal) { + println("TRACE: ${message()}") + } + } + + fun debug(message: () -> String?) { + if (level.ordinal <= LogLevel.DEBUG.ordinal) { + println("DEBUG: ${message()}") + } + } + + fun info(message: () -> String?) { + if (level.ordinal <= LogLevel.INFO.ordinal) { + println("INFO: ${message()}") + } + } + + fun warn(e: Throwable? = null, message: () -> String?) { + if (level.ordinal <= LogLevel.WARN.ordinal) { + println("WARN: ${message()}") + e?.printStackTrace() + } + } + + fun error(e: Throwable? = null, message: () -> String?) { + if (level.ordinal <= LogLevel.ERROR.ordinal) { + println("ERROR: ${message()}") + e?.printStackTrace() + } + } + + fun fatal(e: Throwable, message: () -> String?) { + println("FATAL: ${message()}") + e.printStackTrace() + } +} diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/Main.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/Main.kt index 6692ed0..d15f86e 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/Main.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/Main.kt @@ -1,15 +1,13 @@ package nl.astraeus.vst.chip import kotlinx.browser.document -import kotlinx.browser.window import nl.astraeus.komp.Komponent import nl.astraeus.komp.UnsafeMode -import nl.astraeus.vst.chip.midi.Broadcaster +import nl.astraeus.vst.chip.logger.log import nl.astraeus.vst.chip.midi.Midi -import nl.astraeus.vst.chip.midi.MidiMessage import nl.astraeus.vst.chip.view.MainView +import nl.astraeus.vst.chip.ws.WebsocketClient import nl.astraeus.vst.ui.css.CssSettings -import org.khronos.webgl.Uint8Array fun main() { CssSettings.shortId = false @@ -20,16 +18,7 @@ fun main() { Midi.start() - console.log("Performance", window.performance) - Broadcaster.getChannel(0).postMessage( - MidiMessage( - Uint8Array(arrayOf(0x80.toByte(), 60, 60)), - window.performance.now() - ) - ) - - window.setInterval({ - Broadcaster.sync() - }, 1000) - + WebsocketClient.connect { + log.debug { "Connected to server" } + } } diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/VstChipWorklet.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/VstChipWorklet.kt index 44ebf69..807ccc8 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/VstChipWorklet.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/audio/VstChipWorklet.kt @@ -1,5 +1,6 @@ package nl.astraeus.vst.chip.audio +import nl.astraeus.vst.chip.PatchDTO import nl.astraeus.vst.chip.view.MainView import nl.astraeus.vst.chip.view.WaveformView import nl.astraeus.vst.ui.util.uInt8ArrayOf @@ -10,7 +11,7 @@ import org.w3c.dom.MessageEvent import kotlin.experimental.and object VstChipWorklet : AudioNode( - "vst-chip-worklet.js", + "/vst-chip-worklet.js", "vst-chip-processor" ) { var waveform: Int = 0 @@ -30,44 +31,74 @@ object VstChipWorklet : AudioNode( set(value) { field = value super.postMessage( - uInt8ArrayOf(0xb0 + midiChannel, 0x46, (value * 127).toInt()) + uInt8ArrayOf(0xb0 + midiChannel, 7, (value * 127).toInt()) ) } var dutyCycle = 0.5 set(value) { field = value super.postMessage( - uInt8ArrayOf(0xb0 + midiChannel, 0x4a, (value * 127).toInt()) + uInt8ArrayOf(0xb0 + midiChannel, 0x47, (value * 127).toInt()) ) } var fmModFreq = 0.0 set(value) { field = value super.postMessage( - uInt8ArrayOf(0xb0 + midiChannel, 0x4b, (value * 127).toInt()) + uInt8ArrayOf(0xb0 + midiChannel, 0x4a, (value * 127).toInt()) ) } var fmModAmp = 0.0 set(value) { field = value super.postMessage( - uInt8ArrayOf(0xb0 + midiChannel, 0x4c, (value * 127).toInt()) + uInt8ArrayOf(0xb0 + midiChannel, 0x4b, (value * 127).toInt()) ) } var amModFreq = 0.0 set(value) { field = value super.postMessage( - uInt8ArrayOf(0xb0 + midiChannel, 0x47, (value * 127).toInt()) + uInt8ArrayOf(0xb0 + midiChannel, 0x4c, (value * 127).toInt()) ) } var amModAmp = 0.0 + set(value) { + field = value + super.postMessage( + uInt8ArrayOf(0xb0 + midiChannel, 0x4d, (value * 127).toInt()) + ) + } + + var attack = 0.1 + set(value) { + field = value + super.postMessage( + uInt8ArrayOf(0xb0 + midiChannel, 0x49, (value * 127).toInt()) + ) + } + var decay = 0.2 + set(value) { + field = value + super.postMessage( + uInt8ArrayOf(0xb0 + midiChannel, 0x4b, (value * 127).toInt()) + ) + } + var sustain = 0.5 + set(value) { + field = value + super.postMessage( + uInt8ArrayOf(0xb0 + midiChannel, 0x46, (value * 127).toInt()) + ) + } + var release = 0.2 set(value) { field = value super.postMessage( uInt8ArrayOf(0xb0 + midiChannel, 0x48, (value * 127).toInt()) ) } + var recording: Float32Array? = null override fun onMessage(message: MessageEvent) { @@ -80,6 +111,10 @@ object VstChipWorklet : AudioNode( } } + fun postDirectlyToWorklet(msg: Any) { + super.postMessage(msg) + } + override fun postMessage(msg: Any) { if (msg is Uint8Array) { if ( @@ -133,4 +168,36 @@ object VstChipWorklet : AudioNode( } } + fun load(patch: PatchDTO) { + waveform = patch.waveform + midiChannel = patch.midiChannel + volume = patch.volume + dutyCycle = patch.dutyCycle + fmModFreq = patch.fmModFreq + fmModAmp = patch.fmModAmp + amModFreq = patch.amModFreq + amModAmp = patch.amModAmp + attack = patch.attack + decay = patch.decay + sustain = patch.sustain + release = patch.release + } + + fun save(): PatchDTO { + return PatchDTO( + waveform = waveform, + midiChannel = midiChannel, + volume = volume, + dutyCycle = dutyCycle, + fmModFreq = fmModFreq, + fmModAmp = fmModAmp, + amModFreq = amModFreq, + amModAmp = amModAmp, + attack = attack, + decay = decay, + sustain = sustain, + release = release + ) + } + } diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt index 2afaf99..46708ce 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/midi/Midi.kt @@ -74,6 +74,13 @@ object Midi { ) } + fun setInput(id: String) { + val selected = inputs.find { it.id == id } + if (selected != null) { + setInput(selected) + } + } + fun setInput(input: MIDIInput?) { console.log("Setting input", input) currentInput?.close() diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt index aa0c96a..fc39481 100644 --- a/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/view/MainView.kt @@ -34,14 +34,16 @@ import nl.astraeus.komp.HtmlBuilder import nl.astraeus.komp.Komponent import nl.astraeus.komp.currentElement import nl.astraeus.vst.chip.audio.VstChipWorklet +import nl.astraeus.vst.chip.audio.VstChipWorklet.midiChannel import nl.astraeus.vst.chip.midi.Midi +import nl.astraeus.vst.chip.ws.WebsocketClient import nl.astraeus.vst.ui.components.KnobComponent import nl.astraeus.vst.ui.css.Css import nl.astraeus.vst.ui.css.Css.defineCss import nl.astraeus.vst.ui.css.Css.noTextSelect import nl.astraeus.vst.ui.css.CssName import nl.astraeus.vst.ui.css.hover -import org.khronos.webgl.Uint8Array +import nl.astraeus.vst.ui.util.uInt8ArrayOf import org.khronos.webgl.get import org.w3c.dom.CanvasRenderingContext2D import org.w3c.dom.HTMLCanvasElement @@ -75,10 +77,11 @@ object WaveformView: Komponent() { val height = ctx.canvas.height.toDouble() val halfHeight = height / 2.0 + ctx.lineWidth = 2.0 ctx.clearRect(0.0, 0.0, width, height) val step = 1000.0 / data.length ctx.beginPath() - ctx.strokeStyle = "rgba(255, 255, 255, 0.5)" + ctx.strokeStyle = "rgba(0, 255, 255, 0.5)" ctx.moveTo(0.0, halfHeight) for (i in 0 until data.length) { ctx.lineTo(i * step, halfHeight - data[i] * halfHeight) @@ -119,6 +122,7 @@ object MainView : Komponent(), CssName { VstChipWorklet.create { started = true requestUpdate() + WebsocketClient.send("LOAD\n") } } } @@ -140,6 +144,7 @@ object MainView : Komponent(), CssName { option { +mi.name value = mi.id + selected = mi.id == Midi.currentInput?.id } } @@ -148,12 +153,7 @@ object MainView : Komponent(), CssName { if (target.value == "none") { Midi.setInput(null) } else { - val selected = Midi.inputs.find { it.id == target.value } - if (selected != null) { - Midi.setInput(selected) - } else if (target.value == "midi-broadcast") { - // - } + Midi.setInput(target.value) } } } @@ -172,71 +172,20 @@ object MainView : Komponent(), CssName { } } div { - span { - +"Midi output: " - select { - option { - +"None" - value = "none" - } - for (mi in Midi.outputs) { - option { - +mi.name - value = mi.id - } - } + span(ButtonBarCss.name) { + +"SAVE" + onClickFunction = { + val patch = VstChipWorklet.save().copy(midiId = Midi.currentInput?.id ?: "") - onChangeFunction = { event -> - val target = event.target as HTMLSelectElement - if (target.value == "none") { - Midi.setOutput(null) - } else { - val selected = Midi.outputs.find { it.id == target.value } - if (selected != null) { - Midi.setOutput(selected) - } - } - } + WebsocketClient.send("SAVE\n${JSON.stringify(patch)}") } } - span { - +"channel:" - input { - type = InputType.number - value = Midi.outputChannel.toString() - onInputFunction = { event -> - val target = event.target as HTMLInputElement - Midi.outputChannel = target.value.toInt() - } - } - } - } - div { - span(ButtonCss.name) { - +"Send note on to output" + span(ButtonBarCss.name) { + +"STOP" onClickFunction = { - val data = Uint8Array( - arrayOf( - 0x90.toByte(), - 0x3c.toByte(), - 0x70.toByte() - ) + VstChipWorklet.postDirectlyToWorklet( + uInt8ArrayOf(0xb0 + midiChannel, 123, 0) ) - Midi.send(data, window.performance.now() + 1000) - Midi.send(data, window.performance.now() + 2000) - } - } - span(ButtonCss.name) { - +"Send note off to output" - onClickFunction = { - val data = Uint8Array( - arrayOf( - 0x90.toByte(), - 0x3c.toByte(), - 0x0.toByte(), - ) - ) - Midi.send(data) } } } @@ -362,6 +311,60 @@ object MainView : Komponent(), CssName { } ) } + div(ControlsCss.name) { + include( + KnobComponent( + value = VstChipWorklet.attack, + label = "Attack", + minValue = 0.0, + maxValue = 1.0, + step = 2.0 / 127.0, + width = 100, + height = 120, + ) { value -> + VstChipWorklet.attack = value + } + ) + include( + KnobComponent( + value = VstChipWorklet.decay, + label = "Decay", + minValue = 0.0, + maxValue = 1.0, + step = 2.0 / 127.0, + width = 100, + height = 120, + ) { value -> + VstChipWorklet.decay = value + } + ) + include( + KnobComponent( + value = VstChipWorklet.sustain, + label = "Sustain", + minValue = 0.0, + maxValue = 1.0, + step = 2.0 / 127.0, + width = 100, + height = 120, + ) { value -> + VstChipWorklet.sustain = value + } + ) + include( + KnobComponent( + value = VstChipWorklet.release, + label = "Release", + minValue = 0.0, + maxValue = 1.0, + step = 2.0 / 127.0, + width = 100, + height = 120, + ) { value -> + VstChipWorklet.release = value + } + ) + } include(WaveformView) } } diff --git a/src/jsMain/kotlin/nl/astraeus/vst/chip/ws/WebsocketClient.kt b/src/jsMain/kotlin/nl/astraeus/vst/chip/ws/WebsocketClient.kt new file mode 100644 index 0000000..2156dbd --- /dev/null +++ b/src/jsMain/kotlin/nl/astraeus/vst/chip/ws/WebsocketClient.kt @@ -0,0 +1,120 @@ +package nl.astraeus.vst.chip.ws + +import kotlinx.browser.window +import nl.astraeus.vst.chip.PatchDTO +import nl.astraeus.vst.chip.audio.VstChipWorklet +import nl.astraeus.vst.chip.midi.Midi +import nl.astraeus.vst.chip.view.MainView +import org.w3c.dom.MessageEvent +import org.w3c.dom.WebSocket +import org.w3c.dom.events.Event + +object WebsocketClient { + var websocket: WebSocket? = null + var interval: Int = 0 + + fun connect(onConnect: () -> Unit) { + close() + + websocket = if (window.location.hostname.contains("localhost") || window.location.hostname.contains("192.168")) { + WebSocket("ws://${window.location.hostname}:9000/ws") + } else { + WebSocket("wss://${window.location.hostname}/ws") + } + + websocket?.also { ws -> + ws.onopen = { + onOpen(ws, it) + onConnect() + } + ws.onmessage = { onMessage(ws, it) } + ws.onclose = { onClose(ws, it) } + ws.onerror = { onError(ws, it) } + } + } + + fun close() { + websocket?.close(-1, "Application closed socket.") + } + + fun onOpen( + ws: WebSocket, + event: Event + ) { + interval = window.setInterval({ + val actualWs = websocket + + if (actualWs == null) { + window.clearInterval(interval) + + console.log("Connection to the server was lost!\\nPlease try again later.") + reconnect() + } + }, 10000) + } + + fun reconnect() { + val actualWs = websocket + + if (actualWs != null) { + if (actualWs.readyState == WebSocket.OPEN) { + console.log("Connection to the server was lost!\\nPlease try again later.") + } else { + window.setTimeout({ + reconnect() + }, 1000) + } + } else { + connect {} + + window.setTimeout({ + reconnect() + }, 1000) + } + } + + fun onMessage( + ws: WebSocket, + event: Event + ) { + if (event is MessageEvent) { + val data = event.data + + if (data is String) { + console.log("Received message: $data") + if (data.startsWith("LOAD")) { + val patchJson = data.substring(5) + val patch = JSON.parse(patchJson) + + Midi.setInput(patch.midiId) + VstChipWorklet.load(patch) + MainView.requestUpdate() + } + } + } + } + + fun onClose( + ws: WebSocket, + event: Event + ): dynamic { + websocket = null + + return "dynamic" + } + + fun onError( + ws: WebSocket, + event: Event + ): dynamic { + console.log("Error websocket!", ws, event) + + websocket = null + + return "dynamic" + } + + fun send(message: String) { + websocket?.send(message) + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/GenerateId.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/GenerateId.kt new file mode 100644 index 0000000..d1e72a8 --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/GenerateId.kt @@ -0,0 +1,16 @@ +package nl.astraeus.vst.chip + +import java.security.SecureRandom + +val idChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +val random = SecureRandom() + +fun generateId(): String { + val id = StringBuilder() + + for (i in 0 until 8) { + id.append(idChars[random.nextInt(idChars.length)]) + } + + return id.toString() +} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/Index.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/Index.kt deleted file mode 100644 index 42da567..0000000 --- a/src/jvmMain/kotlin/nl/astraeus/vst/chip/Index.kt +++ /dev/null @@ -1,2 +0,0 @@ -package nl.astraeus.vst.chip - diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/Main.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/Main.kt index 2626bc7..7b42e14 100644 --- a/src/jvmMain/kotlin/nl/astraeus/vst/chip/Main.kt +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/Main.kt @@ -1,17 +1,46 @@ package nl.astraeus.vst.chip +import com.zaxxer.hikari.HikariConfig import io.undertow.Undertow import io.undertow.UndertowOptions +import io.undertow.server.session.InMemorySessionManager +import io.undertow.server.session.SessionAttachmentHandler +import io.undertow.server.session.SessionCookieConfig +import nl.astraeus.vst.chip.db.Database +import nl.astraeus.vst.chip.logger.LogLevel +import nl.astraeus.vst.chip.logger.Logger +import nl.astraeus.vst.chip.web.RequestHandler fun main() { + Logger.level = LogLevel.DEBUG + Thread.setDefaultUncaughtExceptionHandler { _, e -> e.printStackTrace() } + Class.forName("nl.astraeus.jdbc.Driver") + + Database.initialize(HikariConfig().apply { + driverClassName = "nl.astraeus.jdbc.Driver" + jdbcUrl = "jdbc:stat:webServerPort=6002:jdbc:sqlite:data/chip.db" + username = "sa" + password = "" + maximumPoolSize = 25 + isAutoCommit = false + + validate() + }) + + val sessionHandler = SessionAttachmentHandler( + InMemorySessionManager("vst-session-manager"), + SessionCookieConfig() + ) + sessionHandler.setNext(RequestHandler) + val server = Undertow.builder() .addHttpListener(Settings.port, "localhost") .setIoThreads(4) - .setHandler(RequestHandler) + .setHandler(sessionHandler) .setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 1000) .build() diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/RequestHandler.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/RequestHandler.kt deleted file mode 100644 index 7dc9ef3..0000000 --- a/src/jvmMain/kotlin/nl/astraeus/vst/chip/RequestHandler.kt +++ /dev/null @@ -1,15 +0,0 @@ -package nl.astraeus.vst.chip - -import io.undertow.server.HttpHandler -import io.undertow.server.HttpServerExchange -import io.undertow.server.handlers.resource.PathResourceManager -import io.undertow.server.handlers.resource.ResourceHandler -import java.nio.file.Paths - -object RequestHandler : HttpHandler { - val resourceHandler = ResourceHandler(PathResourceManager(Paths.get("web"))) - - override fun handleRequest(exchange: HttpServerExchange) { - resourceHandler.handleRequest(exchange) - } -} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/BaseDao.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/BaseDao.kt new file mode 100644 index 0000000..4ea410f --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/BaseDao.kt @@ -0,0 +1,170 @@ +package nl.astraeus.vst.chip.db + +import kotlinx.datetime.Instant +import nl.astraeus.vst.chip.logger.log +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.Timestamp + +fun Instant.toSqlTimestamp() = Timestamp(this.toEpochMilliseconds()) +fun Timestamp.toDateTimeInstant() = Instant.fromEpochMilliseconds(this.time) + +data class SqlStatement( + val sql: String, + val prepareParameters: T.(PreparedStatement) -> Unit +) + +data class SqlQuery( + val sql: String, + val resultMapper: (ResultSet) -> T +) + +abstract class QueryProvider { + abstract val tableName: String + open val idQuery: String + get() = "SELECT * FROM $tableName WHERE ID = ?" + abstract val resultSetMapper: (ResultSet) -> T + open val find: SqlQuery + get() = SqlQuery( + idQuery, + resultSetMapper + ) + abstract val insert: SqlStatement + abstract val update: SqlStatement + open val delete: SqlStatement + get() = SqlStatement( + "DELETE FROM $tableName WHERE ID = ?" + ) { ps -> + ps.setLong(1, getPK()[0] as Long) + } +} + +abstract class BaseDao { + abstract val queryProvider: QueryProvider + open val autogeneratedPrimaryKey: Boolean = true + + open fun insert(entity: T) { + executeInsert(entity, "insert", queryProvider.insert) + } + + open fun update(entity: T): Int = executeUpdate( + entity, + "update", + queryProvider.update, + true + ) + + open fun upsert(entity: T) { + if ((entity.getPK()[0] as Long) == 0L) { + insert(entity) + } else { + update(entity) + } + } + + open fun delete(entity: T) { + executeUpdate(entity, "delete", queryProvider.delete, true) + } + + open fun find( + id: Long + ): T? { + return executeQuery( + "find", + queryProvider.find + ) { ps -> + ps.setLong(1, id) + }.firstOrNull() + } + + protected fun executeSQLUpdate( + sql: String, + parameterSetter: (PreparedStatement) -> Unit + ): Int { + return transaction { con -> + con.prepareStatement(sql).use { ps -> + parameterSetter(ps) + + ps.executeUpdate() + } + } + } + + protected fun executeQuery( + label: String, + statement: SqlQuery, + prepareParameters: (PreparedStatement) -> Unit, + ): List { + return transaction { con -> + log.debug { "Executing query [$label] - [${statement.sql}]" } + val result = mutableListOf() + + con.prepareStatement(statement.sql)?.use { ps -> + prepareParameters(ps) + + val rs = ps.executeQuery() + + while (rs.next()) { + result.add(statement.resultMapper(rs)) + } + } + + result + } + } + + protected fun executeInsert( + entity: T, + label: String, + statement: SqlStatement, + checkSingleRow: Boolean = false + ) { + transaction { con -> + log.debug { "Executing insert [$label] - [${statement.sql}] - [$entity]" } + con.prepareStatement(statement.sql)?.use { ps -> + statement.prepareParameters(entity, ps) + + val rows = if (checkSingleRow) { + ps.execute() + 1 + } else { + ps.executeUpdate() + } + + if (autogeneratedPrimaryKey) { + val keyResult = ps.generatedKeys + if (keyResult.next()) { + entity.setPK(arrayOf(keyResult.getLong(1))) + } + } + + check(rows == 1) { + "Statement [$label] affected more than 1 row! [${statement.sql}]" + } + } + } + } + + protected fun executeUpdate( + entity: T, + label: String, + statement: SqlStatement, + checkSingleRow: Boolean = false + ): Int = transaction { con -> + var rows = 1 + + log.debug { "Executing update [$label] - [${statement.sql}] - [$entity]" } + con.prepareStatement(statement.sql)?.use { ps -> + statement.prepareParameters(entity, ps) + + rows = ps.executeUpdate() + + check(checkSingleRow || rows == 1) { + "Statement [$label] affected more than 1 row! [${statement.sql}]" + } + } + + rows + } + +} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Database.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Database.kt new file mode 100644 index 0000000..2276275 --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Database.kt @@ -0,0 +1,99 @@ +package nl.astraeus.vst.chip.db + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import java.sql.Connection +import java.util.* +import java.util.concurrent.atomic.AtomicBoolean + +enum class TxScope { + REQUIRED, + /* if needed we need to switch db, sqlite only allows one writer/connection */ + //REQUIRES_NEW +} + +private val currentConnection = ThreadLocal() + +fun transaction( + scope: TxScope = TxScope.REQUIRED, + block: (Connection) -> T +): T { + val hasConnection = currentConnection.get() != null + var oldConnection: Connection? = null + + if (!hasConnection) { + currentConnection.set(Database.getConnection()) + /* + } else if (scope == TxScope.REQUIRES_NEW) { + oldConnection = currentConnection.get() + + currentConnection.set(Database.getConnection()) + */ + } + + val connection = currentConnection.get() + + try { + val result = block(connection) + + connection.commit() + + return result + } finally { + if (!hasConnection) { + currentConnection.set(oldConnection) + connection.close() + } + } +} + +object Database { + + var ds: HikariDataSource? = null + + fun initialize(config: HikariConfig) { + val properties = Properties() + properties["journal_mode"] = "WAL" + + + config.dataSourceProperties = properties + config.addDataSourceProperty("cachePrepStmts", "true") + config.addDataSourceProperty("prepStmtCacheSize", "250") + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048") + + ds = HikariDataSource(config) + Migrations.databaseVersionTableCreated = AtomicBoolean(false) + Migrations.updateDatabaseIfNeeded() + } + + fun getConnection() = ds?.connection ?: error("Database has not been initialized!") + + /* + val ds: HikariDataSource + + init { + val properties = Properties() + properties["journal_mode"] = "WAL" + + val config = HikariConfig().apply { + driverClassName = "nl.astraeus.jdbc.Driver" + jdbcUrl = "jdbc:stat:webServerPort=6001:jdbc:sqlite:data/daw3.db" + username = "sa" + password = "" + maximumPoolSize = 25 + isAutoCommit = false + dataSourceProperties = properties + validate() + } + + config.addDataSourceProperty("cachePrepStmts", "true") + config.addDataSourceProperty("prepStmtCacheSize", "250") + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048") + + ds = HikariDataSource(config) + } + + fun getConnection() = ds.connection + */ + +} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Entity.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Entity.kt new file mode 100644 index 0000000..6c82712 --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Entity.kt @@ -0,0 +1,16 @@ +package nl.astraeus.vst.chip.db + +interface Entity { + fun getPK(): Array + fun setPK(pks: Array) +} + +interface EntityId : Entity { + var id: Long + + override fun getPK(): Array = arrayOf(id) + + override fun setPK(pks: Array) { + id = pks[0] as Long + } +} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Migrations.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Migrations.kt new file mode 100644 index 0000000..4207f92 --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/Migrations.kt @@ -0,0 +1,106 @@ +package nl.astraeus.vst.chip.db + +import nl.astraeus.vst.chip.logger.log +import java.sql.Connection +import java.sql.SQLException +import java.sql.Timestamp +import java.util.concurrent.atomic.AtomicBoolean + +sealed class Migration { + class Query( + val query: String + ) : Migration() { + override fun toString(): String { + return query + } + } + + class Code( + val code: (Connection) -> Unit + ) : Migration() { + override fun toString(): String { + return code.toString() + } + } +} + +val DATABASE_MIGRATIONS = arrayOf( + Migration.Query( + """ + CREATE TABLE DATABASE_VERSION ( + ID INTEGER PRIMARY KEY, + QUERY TEXT, + EXECUTED TIMESTAMP + ) + """.trimIndent() + ), + Migration.Query(PATCH_CREATE_QUERY), +) + +object Migrations { + var databaseVersionTableCreated = AtomicBoolean(false) + + fun updateDatabaseIfNeeded() { + try { + transaction { con -> + con.prepareStatement( + """ + SELECT MAX(ID) FROM DATABASE_VERSION + """.trimIndent() + ).use { ps -> + ps.executeQuery().use { rs -> + databaseVersionTableCreated.compareAndSet(false, true) + + if (rs.next()) { + val maxId = rs.getInt(1) + + for (index in maxId + 1.. + log.debug { + "Executing migration $index - [${DATABASE_MIGRATIONS[index]}]" + } + val description = when ( + val migration = DATABASE_MIGRATIONS[index] + ) { + is Migration.Query -> { + con.prepareStatement(migration.query).use { ps -> + ps.execute() + } + + migration.query + } + + is Migration.Code -> { + migration.code(con) + + migration.code.toString() + } + } + con.prepareStatement("INSERT INTO DATABASE_VERSION VALUES (?, ?, ?)").use { ps -> + ps.setInt(1, index) + ps.setString(2, description) + ps.setTimestamp(3, Timestamp(System.currentTimeMillis())) + + ps.execute() + } + } + } + +} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchDao.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchDao.kt new file mode 100644 index 0000000..e24d5ba --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchDao.kt @@ -0,0 +1,31 @@ +package nl.astraeus.vst.chip.db + +object PatchDao : BaseDao() { + + override val queryProvider: QueryProvider + get() = PatchEntityQueryProvider + + fun create( + patchId: String, + patch: String + ): PatchEntity { + val result = PatchEntity( + 0, + patchId, + patch + ) + + return result + } + + fun findById(patchId: String): PatchEntity? = executeQuery( + "findById", + SqlQuery( + "SELECT * FROM ${queryProvider.tableName} WHERE PATCH_ID = ?", + queryProvider.resultSetMapper + ) + ) { ps -> + ps.setString(1, patchId) + }.firstOrNull() + +} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchEntity.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchEntity.kt new file mode 100644 index 0000000..80825d3 --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchEntity.kt @@ -0,0 +1,12 @@ +package nl.astraeus.vst.chip.db + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +data class PatchEntity( + override var id: Long, + var patchId: String, + var patch: String, + var created: Instant = Clock.System.now(), + var updated: Instant = Clock.System.now(), +) : EntityId \ No newline at end of file diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchEntityQueryProvider.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchEntityQueryProvider.kt new file mode 100644 index 0000000..cc3650c --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/db/PatchEntityQueryProvider.kt @@ -0,0 +1,64 @@ +package nl.astraeus.vst.chip.db + +import java.sql.ResultSet +import java.sql.Types + +val PATCH_CREATE_QUERY = """ + CREATE TABLE INSTRUMENTS ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + PATCH_ID TEXT, + PATCH TEXT, + CREATED TIMESTAMP, + UPDATED TIMESTAMP + ) + """.trimIndent() + +object PatchEntityQueryProvider : QueryProvider() { + override val tableName: String + get() = "INSTRUMENTS" + override val resultSetMapper: (ResultSet) -> PatchEntity + get() = { rs -> + PatchEntity( + rs.getLong(1), + rs.getString(2), + rs.getString(3), + rs.getTimestamp(4).toDateTimeInstant(), + rs.getTimestamp(5).toDateTimeInstant() + ) + } + override val insert: SqlStatement + get() = SqlStatement( + """ + INSERT INTO $tableName ( + ID, + PATCH_ID, + PATCH, + CREATED, + UPDATED + ) VALUES ( + ?,?,?,?,? + ) + """.trimIndent() + ) { ps -> + ps.setNull(1, Types.BIGINT) + ps.setString(2, patchId) + ps.setString(3, patch) + ps.setTimestamp(4, created.toSqlTimestamp()) + ps.setTimestamp(5, updated.toSqlTimestamp()) + } + override val update: SqlStatement + get() = SqlStatement( + """ + UPDATE $tableName + SET PATCH_ID = ?, + PATCH = ?, + UPDATED = ? + WHERE ID = ? + """.trimIndent() + ) { ps -> + ps.setString(1, patchId) + ps.setString(2, patch) + ps.setTimestamp(3, updated.toSqlTimestamp()) + ps.setLong(4, id) + } +} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/web/Index.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/web/Index.kt new file mode 100644 index 0000000..25cda93 --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/web/Index.kt @@ -0,0 +1,42 @@ +package nl.astraeus.vst.chip.web + +import kotlinx.html.body +import kotlinx.html.head +import kotlinx.html.html +import kotlinx.html.meta +import kotlinx.html.script +import kotlinx.html.stream.appendHTML +import kotlinx.html.title + +fun generateIndex(patch: String?): String { + val result = StringBuilder(); + + if (patch == null) { + result.appendHTML(true).html { + head { + title { +"VST Chip" } + } + body { + script { + type = "application/javascript" + src = "/vst-chip-worklet-ui.js" + } + } + } + } else { + result.appendHTML(true).html { + head { + title { +"VST Chip" } + meta { + httpEquiv = "refresh" + content = "0; url=/patch/$patch" + } + } + body { + +"Redirecting to patch $patch..." + } + } + } + + return result.toString() +} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/web/RequestHandler.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/web/RequestHandler.kt new file mode 100644 index 0000000..c36c8e1 --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/web/RequestHandler.kt @@ -0,0 +1,128 @@ +package nl.astraeus.vst.chip.web + +import io.undertow.Handlers.websocket +import io.undertow.server.HttpHandler +import io.undertow.server.HttpServerExchange +import io.undertow.server.handlers.PathHandler +import io.undertow.server.handlers.resource.PathResourceManager +import io.undertow.server.handlers.resource.ResourceHandler +import io.undertow.server.session.Session +import io.undertow.server.session.SessionConfig +import io.undertow.server.session.SessionManager +import io.undertow.websockets.WebSocketConnectionCallback +import io.undertow.websockets.core.AbstractReceiveListener +import io.undertow.websockets.core.BufferedBinaryMessage +import io.undertow.websockets.core.BufferedTextMessage +import io.undertow.websockets.core.WebSocketChannel +import io.undertow.websockets.core.WebSockets +import io.undertow.websockets.spi.WebSocketHttpExchange +import nl.astraeus.vst.chip.db.PatchDao +import nl.astraeus.vst.chip.db.PatchEntity +import nl.astraeus.vst.chip.db.transaction +import nl.astraeus.vst.chip.generateId +import java.nio.file.Paths + +class WebsocketHandler( + val session: Session? +) : AbstractReceiveListener(), WebSocketConnectionCallback { + + override fun onConnect(exchange: WebSocketHttpExchange, channel: WebSocketChannel) { + channel.receiveSetter.set(this) + channel.resumeReceives() + } + + override fun onFullTextMessage(channel: WebSocketChannel, message: BufferedTextMessage) { + val vstSession = session?.getAttribute("html-session") as? VstSession + + val data = message.data + val commandLength = data.indexOf('\n') + if (commandLength > 0) { + val command = data.substring(0, commandLength) + val value = data.substring(commandLength + 1) + + when (command) { + "SAVE" -> { + val patchId = vstSession?.patchId + if (patchId != null) { + transaction { + val patchEntity = PatchDao.findById(patchId) + + if (patchEntity != null) { + PatchDao.update(patchEntity.copy(patch = value)) + } else { + PatchDao.insert(PatchEntity(0, patchId, value)) + } + } + WebSockets.sendText("SAVED\n$patchId", channel, null) + } + } + + "LOAD" -> { + val patchId = vstSession?.patchId + if (patchId != null) { + transaction { + val patchEntity = PatchDao.findById(patchId) + + if (patchEntity != null) { + WebSockets.sendText("LOAD\n${patchEntity.patch}", channel, null) + } + } + } + } + } + } + } + + override fun onFullBinaryMessage(channel: WebSocketChannel?, message: BufferedBinaryMessage?) { + // do nothing + } +} + +object WebsocketConnectHandler : HttpHandler { + override fun handleRequest(exchange: HttpServerExchange) { + val sessionManager = exchange.getAttachment(SessionManager.ATTACHMENT_KEY) + val sessionConfig = exchange.getAttachment(SessionConfig.ATTACHMENT_KEY) + + val httpSession: Session? = sessionManager.getSession(exchange, sessionConfig) + + websocket(WebsocketHandler(httpSession)).handleRequest(exchange) + } +} + +object PatchHandler : HttpHandler { + override fun handleRequest(exchange: HttpServerExchange) { + if (exchange.requestPath.startsWith("/patch/")) { + val patchId = exchange.requestPath.substring(7) + val sessionManager = exchange.getAttachment(SessionManager.ATTACHMENT_KEY) + val sessionConfig = exchange.getAttachment(SessionConfig.ATTACHMENT_KEY) + var httpSession: Session? = sessionManager.getSession(exchange, sessionConfig) + + if (httpSession == null) { + httpSession = sessionManager.createSession(exchange, sessionConfig) + } + httpSession?.setAttribute("html-session", VstSession(patchId)) + + exchange.responseSender.send(generateIndex(null)) + } else { + val patchId = generateId() + + exchange.responseSender.send(generateIndex(patchId)) + } + } +} + +object RequestHandler : HttpHandler { + val resourceHandler = ResourceHandler(PathResourceManager(Paths.get("web"))) + val pathHandler = PathHandler(resourceHandler) + + init { + pathHandler.addExactPath("/", PatchHandler) + pathHandler.addExactPath("/index.html", PatchHandler) + pathHandler.addPrefixPath("/patch", PatchHandler) + pathHandler.addExactPath("/ws", WebsocketConnectHandler) + } + + override fun handleRequest(exchange: HttpServerExchange) { + pathHandler.handleRequest(exchange) + } +} diff --git a/src/jvmMain/kotlin/nl/astraeus/vst/chip/web/Session.kt b/src/jvmMain/kotlin/nl/astraeus/vst/chip/web/Session.kt new file mode 100644 index 0000000..eb0c91b --- /dev/null +++ b/src/jvmMain/kotlin/nl/astraeus/vst/chip/web/Session.kt @@ -0,0 +1,5 @@ +package nl.astraeus.vst.chip.web + +class VstSession( + val patchId: String +) \ No newline at end of file