Compare commits

...

10 Commits

Author SHA1 Message Date
8df6a4fff6 Also search on name when setting midi port 2024-07-13 16:44:33 +02:00
f2269c8865 Also search on name when setting midi port 2024-07-02 19:17:35 +02:00
6554fd746a Volume click fix 2024-07-01 21:13:06 +02:00
976328ed69 Save patch 2024-06-30 20:32:43 +02:00
194857d687 Cleanup 2024-06-29 20:01:16 +02:00
f22a800c93 Layout 2024-06-28 19:22:16 +02:00
ccc7e9a4e9 Modulation, waveforms 2024-06-28 17:07:58 +02:00
b02c7733b0 Add inputs 2024-06-27 20:08:24 +02:00
0cfd6f31d5 Add input 2024-06-27 16:40:33 +02:00
05764ec588 Use vst-ui-base 2024-06-27 12:32:17 +02:00
53 changed files with 3609 additions and 888 deletions

2
.gitignore vendored
View File

@@ -41,7 +41,7 @@ bin/
### Mac OS ###
.DS_Store
web
/web
.kotlin
.idea

View File

@@ -2,7 +2,7 @@
<artifact type="jar" name="audio-worklet-js-1.0.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/audio-worklet/build/libs</output-path>
<root id="archive" name="audio-worklet-js-1.0.0-SNAPSHOT.jar">
<element id="module-output" name="vst-chip.audio-worklet.jsMain" />
<element id="module-output" name="vst-string.audio-worklet.jsMain" />
</root>
</artifact>
</component>

View File

@@ -2,7 +2,7 @@
<artifact type="jar" name="audio-worklet-jvm-1.0.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/audio-worklet/build/libs</output-path>
<root id="archive" name="audio-worklet-jvm-1.0.0-SNAPSHOT.jar">
<element id="module-output" name="vst-chip.audio-worklet.jvmMain" />
<element id="module-output" name="vst-string.audio-worklet.jvmMain" />
</root>
</artifact>
</component>

View File

@@ -2,7 +2,7 @@
<artifact type="jar" name="common-js-1.0.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/common/build/libs</output-path>
<root id="archive" name="common-js-1.0.0-SNAPSHOT.jar">
<element id="module-output" name="vst-chip.common.jsMain" />
<element id="module-output" name="vst-string.common.jsMain" />
</root>
</artifact>
</component>

View File

@@ -2,7 +2,7 @@
<artifact type="jar" name="common-jvm-1.0.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/common/build/libs</output-path>
<root id="archive" name="common-jvm-1.0.0-SNAPSHOT.jar">
<element id="module-output" name="vst-chip.common.jvmMain" />
<element id="module-output" name="vst-string.common.jvmMain" />
</root>
</artifact>
</component>

View File

@@ -4,7 +4,7 @@
<option name="executionName"/>
<option name="externalProjectPath" value="$PROJECT_DIR$"/>
<option name="externalSystemIdString" value="GRADLE"/>
<option name="scriptParameters" value="-DmainClass=nl.astraeus.vst.chip.MainKt --quiet"/>
<option name="scriptParameters" value="-DmainClass=nl.astraeus.vst.string.MainKt --quiet"/>
<option name="taskDescriptions">
<list/>
</option>

View File

@@ -1,3 +1,7 @@
@file:OptIn(ExperimentalKotlinGradlePluginApi::class, ExperimentalDistributionDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalDistributionDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackOutput
buildscript {
@@ -17,13 +21,13 @@ kotlin {
browser {
commonWebpackConfig {
outputFileName = "vst-chip-worklet.js"
outputFileName = "vst-string-worklet.js"
sourceMaps = true
}
webpackTask {
output.libraryTarget = KotlinWebpackOutput.Target.VAR
output.library = "vstChipWorklet"
output.library = "vstStringWorklet"
}
distribution {

View File

@@ -1,311 +0,0 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.chip
import nl.astraeus.vst.AudioWorkletProcessor
import nl.astraeus.vst.Note
import nl.astraeus.vst.registerProcessor
import nl.astraeus.vst.sampleRate
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Int32Array
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
import org.khronos.webgl.set
import org.w3c.dom.MessageEvent
import kotlin.math.PI
import kotlin.math.sin
val POLYPHONICS = 10
val PI2 = PI * 2
@ExperimentalJsExport
@JsExport
enum class NoteState {
ON,
RELEASED,
OFF
}
@ExperimentalJsExport
@JsExport
class PlayingNote(
val note: Int,
var velocity: Int = 0
) {
fun retrigger(velocity: Int) {
this.velocity = velocity
state = NoteState.ON
sample = 0
attackSamples = 2500
releaseSamples = 10000
}
var state = NoteState.OFF
var cycleOffset = 0.0
var sample = 0
var attackSamples = 2500
var releaseSamples = 10000
var actualVolume = 0f
}
enum class Waveform {
SINE,
SQUARE,
TRIANGLE,
SAWTOOTH
}
@ExperimentalJsExport
@JsExport
class VstChipProcessor : AudioWorkletProcessor() {
var midiChannel = 0
val notes = Array(POLYPHONICS) {
PlayingNote(
0
)
}
var waveform = Waveform.SINE.ordinal
var dutyCycle = 0.5
var fmFreq = 0.0
var fmAmp = 0.0
var amFreq = 0.0
var amAmp = 0.0
val sampleLength = 1 / sampleRate.toDouble()
init {
this.port.onmessage = ::handleMessage
Note.updateSampleRate(sampleRate)
}
private fun handleMessage(message: MessageEvent) {
console.log("VstChipProcessor: Received message:", message.data)
val data = message.data
try {
when (data) {
is String -> {
if (data.startsWith("set_channel")) {
val parts = data.split('\n')
if (parts.size == 2) {
midiChannel = parts[1].toInt()
println("Setting channel: $midiChannel")
}
}
}
is Uint8Array -> {
val data32 = Int32Array(data.length)
for (i in 0 until data.length) {
data32[i] = (data[i].toInt() and 0xff)
}
playMidi(data32)
}
is Int32Array -> {
playMidi(data)
}
else ->
console.error("Don't kow how to handle message", message)
}
} catch(e: Exception) {
console.log(e.message, e)
}
}
private fun playMidi(bytes: Int32Array) {
console.log("playMidi", bytes)
if (bytes.length > 0) {
var cmdByte = bytes[0]
val channelCmd = ((cmdByte shr 4) and 0xf) in 0x8 .. 0xe
val channel = cmdByte and 0xf
println("Channel cmd: $channelCmd")
if (channelCmd && channel != midiChannel) {
console.log("Wrong channel", midiChannel, bytes)
return
}
cmdByte = cmdByte and 0xf0
//console.log("Received", bytes)
when(cmdByte) {
0x90 -> {
if (bytes.length == 3) {
val note = bytes[1]
val velocity = bytes[2]
if (velocity > 0) {
noteOn(note, velocity)
} else {
noteOff(note)
}
}
}
0x80 -> {
if (bytes.length >= 2) {
val note = bytes[1]
noteOff(note)
}
}
0xc9 -> {
if (bytes.length >= 1) {
val waveform = bytes[1]
if (waveform < 4) {
this.waveform = waveform
}
}
}
0xb0 -> {
if (bytes.length == 3) {
val knob = bytes[1]
val value = bytes[2]
when (knob) {
0x4a -> {
dutyCycle = value / 127.0
}
0x4b -> {
fmFreq = value / 127.0
}
0x4c -> {
fmAmp = value / 127.0
}
0x47 -> {
amFreq = value / 127.0
}
0x48 -> {
amAmp = value / 127.0
}
}
}
}
0xe0 -> {
if (bytes.length == 3) {
val lsb = bytes[1]
val msb = bytes[2]
amFreq = (((msb - 0x40) + (lsb / 127.0)) / 0x40) * 10.0
}
}
}
}
}
private fun noteOn(note: Int, velocity: Int) {
for (i in 0 until POLYPHONICS) {
if (notes[i].note == note) {
notes[i].retrigger(velocity)
return
}
}
for (i in 0 until POLYPHONICS) {
if (notes[i].state == NoteState.OFF) {
notes[i] = PlayingNote(
note,
velocity
)
notes[i].state = NoteState.ON
val n = Note.fromMidi(note)
//console.log("Playing note: ${n.sharp} (${n.freq})")
break
}
}
}
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
break
}
}
}
override fun process (
inputs: Array<Array<Float32Array>>,
outputs: Array<Array<Float32Array>>,
parameters: dynamic
) : Boolean {
val samples = outputs[0][0].length
val left = outputs[0][0]
val right = outputs[0][1]
for (note in notes) {
if (note.state != NoteState.OFF) {
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)
}
note.actualVolume += (targetVolume - note.actualVolume) * 0.0001f
if (note.state == NoteState.RELEASED && note.actualVolume <= 0) {
note.state = NoteState.OFF
}
var cycleOffset = note.cycleOffset
val fmModulation = sin(sampleLength * fmFreq * 10f * PI2 * note.sample).toFloat() * fmAmp * 5f
val amModulation = 1f + (sin(sampleLength * amFreq * 10f * PI2 * note.sample) * amAmp).toFloat()
cycleOffset += fmModulation
val waveValue: Float = when (waveform) {
0 -> {
sin(cycleOffset * PI2).toFloat()
}
1 -> {
if (cycleOffset < dutyCycle) { 1f } else { -1f }
}
2 -> when {
cycleOffset < 0.25 -> 4 * cycleOffset
cycleOffset < 0.75 -> 2 - 4 * cycleOffset
else -> 4 * cycleOffset - 4
}.toFloat()
3 -> {
((cycleOffset * 2f) - 1f).toFloat()
}
else -> {
if (cycleOffset < 0.5) { 1f } else { -1f }
}
}
left[i] = left[i] + waveValue * note.actualVolume * 0.3f * amModulation
right[i] = right[i] + waveValue * note.actualVolume * 0.3f * amModulation
note.cycleOffset += sampleDelta
if (cycleOffset > 1f) {
note.cycleOffset -= 1f
}
note.sample++
}
}
}
return true
}
}
fun main() {
registerProcessor("vst-chip-processor", VstChipProcessor::class.js)
println("VstChipProcessor registered!")
}

View File

@@ -0,0 +1,264 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.string
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
import org.khronos.webgl.Int32Array
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
import org.khronos.webgl.set
import org.w3c.dom.MessageEvent
import kotlin.math.PI
import kotlin.math.min
val POLYPHONICS = 10
val PI2 = PI * 2
@ExperimentalJsExport
@JsExport
class PlayingNote(
val note: Int,
var velocity: Int = 0
) {
fun retrigger(velocity: Int) {
this.velocity = velocity
sample = 0
noteStart = currentTime
noteRelease = null
}
var noteStart = currentTime
var noteRelease: Double? = null
var cycleOffset = 0.0
var sample = 0
var actualVolume = 0f
}
@ExperimentalJsExport
@JsExport
enum class RecordingState {
STOPPED,
WAITING_TO_START,
RECORDING
}
@ExperimentalJsExport
@JsExport
class VstStringProcessor : AudioWorkletProcessor() {
var midiChannel = 0
var volume = 0.75f
var damping = 0.996
val recordingBuffer = Float32Array(sampleRate / 60)
var recordingState = RecordingState.STOPPED
var recordingSample = 0
var recordingStart = 0
val strings = Array(POLYPHONICS) {
PhysicalString(sampleRate, damping)
}
init {
this.port.onmessage = ::handleMessage
Note.updateSampleRate(sampleRate)
}
private fun handleMessage(message: MessageEvent) {
//console.log("VstChipProcessor: Received message:", message.data)
val data = message.data
try {
when (data) {
is String -> {
when (data) {
"start_recording" -> {
port.postMessage(recordingBuffer)
if (recordingState == RecordingState.STOPPED) {
recordingState = RecordingState.WAITING_TO_START
recordingSample = 0
}
}
else ->
if (data.startsWith("set_channel")) {
val parts = data.split('\n')
if (parts.size == 2) {
midiChannel = parts[1].toInt()
println("Setting channel: $midiChannel")
}
}
}
}
is Uint8Array -> {
val data32 = Int32Array(data.length)
for (i in 0 until data.length) {
data32[i] = (data[i].toInt() and 0xff)
}
playMidi(data32)
}
is Int32Array -> {
playMidi(data)
}
else ->
console.error("Don't kow how to handle message", message)
}
} catch (e: Exception) {
console.log(e.message, e)
}
}
private fun playMidi(bytes: Int32Array) {
console.log("playMidi", bytes)
if (bytes.length > 0) {
var cmdByte = bytes[0]
val channelCmd = ((cmdByte shr 4) and 0xf) != 0xf0
val channel = cmdByte and 0xf
println("Channel cmd: $channelCmd")
if (channelCmd && channel != midiChannel) {
console.log("Wrong channel", midiChannel, bytes)
return
}
cmdByte = cmdByte and 0xf0
//console.log("Received", bytes)
when (cmdByte) {
0x90 -> {
if (bytes.length == 3) {
val note = bytes[1]
val velocity = bytes[2]
if (velocity > 0) {
noteOn(note, velocity)
} else {
noteOff(note)
}
}
}
0x80 -> {
if (bytes.length >= 2) {
val note = bytes[1]
noteOff(note)
}
}
0xb0 -> {
if (bytes.length == 3) {
val knob = bytes[1]
val value = bytes[2]
when (knob) {
7 -> {
volume = value / 127f
}
0x47 -> {
damping = 0.8 + value / 127.0
}
}
}
}
}
}
}
private fun noteOn(midiNote: Int, velocity: Int) {
val note = Note.fromMidi(midiNote)
for (i in 0 until POLYPHONICS) {
if (strings[i].currentNote == note) {
strings[i].pluck(note, velocity / 127.0)
strings[i].damping = damping
return
}
}
for (i in 0 until POLYPHONICS) {
if (strings[i].available) {
strings[i].pluck(note, velocity / 127.0)
strings[i].damping = damping
break
}
}
}
private fun noteOff(midiNote: Int) {
val note = Note.fromMidi(midiNote)
for (i in 0 until POLYPHONICS) {
if (strings[i].currentNote.ordinal == note.ordinal) {
strings[i].available = true
strings[i].damping = damping * 0.9
}
}
}
override fun process(
inputs: Array<Array<Float32Array>>,
outputs: Array<Array<Float32Array>>,
parameters: dynamic
): Boolean {
val samples = outputs[0][0].length
val left = outputs[0][0]
val right = outputs[0][1]
var lowestNote = 200
for (string in strings) {
if (string.available) {
lowestNote = min(lowestNote, string.currentNote.ordinal)
}
}
if (lowestNote == 200 && recordingState == RecordingState.WAITING_TO_START) {
recordingState = RecordingState.RECORDING
recordingSample = 0
recordingStart = 0
}
for (string in strings) {
for (i in 0 until samples) {
val waveValue: Float = string.tick().toFloat()
left[i] = left[i] + waveValue * volume
right[i] = right[i] + waveValue * volume
if (lowestNote == string.currentNote.ordinal && recordingState == RecordingState.WAITING_TO_START && string.index == 0) {
recordingState = RecordingState.RECORDING
recordingSample = 0
recordingStart = 0
}
}
}
if (recordingState == RecordingState.RECORDING) {
for (i in recordingStart until samples) {
recordingBuffer[recordingSample] = (left[i] + right[i]) / 2f
if (recordingSample < recordingBuffer.length - 1) {
recordingSample++
} else {
recordingState = RecordingState.STOPPED
}
}
recordingStart = 0
}
return true
}
}
fun main() {
registerProcessor("vst-string-processor", VstStringProcessor::class.js)
println("VstStringProcessor registered!")
}

View File

@@ -1,5 +1,3 @@
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackOutput
buildscript {
apply(from = "common.gradle.kts")
}
@@ -21,7 +19,7 @@ kotlin {
binaries.executable()
browser {
commonWebpackConfig {
outputFileName = "vst-chip-worklet-ui.js"
outputFileName = "vst-string-worklet-ui.js"
sourceMaps = true
}
@@ -40,12 +38,14 @@ 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 {
dependencies {
//base
implementation("nl.astraeus:kotlin-komponent-js:1.2.2")
implementation("nl.astraeus:vst-ui-base:1.0.0-SNAPSHOT")
}
}
val jsTest by getting {
@@ -57,7 +57,14 @@ kotlin {
dependencies {
//base
implementation("io.undertow:undertow-core:2.3.13.Final")
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")
}
}

View File

@@ -1,5 +1,7 @@
package nl.astraeus.vst
import kotlin.js.ExperimentalJsExport
import kotlin.js.JsExport
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
@@ -11,6 +13,8 @@ import kotlin.math.round
* Time: 11:50
*/
@ExperimentalJsExport
@JsExport
enum class Note(
val sharp: String,
val flat: String

View File

@@ -0,0 +1,80 @@
package nl.astraeus.vst.string
import nl.astraeus.vst.Note
import kotlin.js.ExperimentalJsExport
import kotlin.js.JsExport
import kotlin.math.round
expect fun randomDouble(): Double
@ExperimentalJsExport
@JsExport
class PhysicalString(
val sampleRate: Int,
var damping: Double,
) {
val sampleLength = 1.0 / sampleRate.toDouble()
val maxLength = sampleRate / Note.G9.freq
var length = 1
val buffer = Array(maxLength.toInt() + 1) { 0.0 }
var sample = 0
var index = 0
var remaining = 0.0
var currentNote = Note.C4
var available = true
fun pluck(note: Note, velocity: Double, smoothing: Int = 0) {
available = false
currentNote = note
length = round(sampleRate / note.freq).toInt()
sample = 0
index = 0
for (i in 0..<length) {
if (i < length / 2) {
buffer[i] = randomDouble() * velocity
} else {
buffer[i] = -randomDouble() * velocity
}
//buffer[i] = (randomDouble() - 0.5) * 2.0 * velocity
//buffer[i] = sin(PI * 2 * i/length)
//buffer[i] = (i/length.toDouble() * 2.0) - 1.0 //if (i < length / 2) { 1.0 } else { -1.0 }
}
repeat(smoothing) {
for (i in 0..<length) {
tick()
}
}
}
fun update(time: Double) {
remaining += (time / 1000.0)
while (remaining > sampleLength) {
remaining -= sampleLength
tick()
}
}
fun tick(): Double {
val result = buffer[index]
var newValue = 0.0
newValue += getValueFromBuffer(index + 1) * 0.2
newValue += getValueFromBuffer(index + 2) * 0.3
newValue += getValueFromBuffer(index + 3) * 0.3
newValue += getValueFromBuffer(index + 4) * 0.2
// newValue += getValueFromBuffer(index + 5) * 0.2
// newValue += getValueFromBuffer(index + 6) * 0.3
newValue *= damping
buffer[index] = newValue
index = (index + 1) % length
return result
}
private fun getValueFromBuffer(index: Int): Double {
return buffer[(index + length) % length]
}
}

View File

@@ -0,0 +1,5 @@
package nl.astraeus.vst.string
import kotlin.random.Random
actual fun randomDouble(): Double = Random.nextDouble()

View File

@@ -0,0 +1,5 @@
package nl.astraeus.vst.string
actual fun randomDouble(): Double {
TODO("Not yet implemented")
}

View File

@@ -1,6 +1,6 @@
pluginManagement {
plugins {
kotlin("multiplatform") version "2.0.0"
kotlin("multiplatform") version "2.0.20-Beta1"
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
}
repositories {

View File

@@ -1,6 +1,6 @@
apply(from = "settings.common.gradle.kts")
rootProject.name = "vst-chip"
rootProject.name = "vst-string"
include(":common")
include(":audio-worklet")

View File

@@ -0,0 +1,24 @@
package nl.astraeus.vst.string
import kotlin.js.ExperimentalJsExport
import kotlin.js.JsExport
import kotlin.js.JsName
@ExperimentalJsExport
@JsExport
data class PatchDTO(
@JsName("midiId")
val midiId: String = "",
@JsName("midiName")
val midiName: String = "",
@JsName("midiChannel")
var midiChannel: Int = 0,
@JsName("volume")
var volume: Double = 0.75,
@JsName("damping")
var damping: Double = 0.5,
@JsName("delay")
var delay: Double = 0.0,
@JsName("delayDepth")
var delayDepth: Double = 0.0,
)

View File

@@ -0,0 +1,53 @@
package nl.astraeus.vst.string.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()
}
}

View File

@@ -1,29 +0,0 @@
package nl.astraeus.vst.chip
import kotlinx.browser.document
import kotlinx.browser.window
import nl.astraeus.komp.Komponent
import nl.astraeus.vst.chip.midi.Broadcaster
import nl.astraeus.vst.chip.midi.MidiMessage
import nl.astraeus.vst.chip.midi.Midi
import nl.astraeus.vst.chip.view.MainView
import org.khronos.webgl.Uint8Array
fun main() {
Komponent.create(document.body!!, MainView)
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)
}

View File

@@ -1,10 +0,0 @@
package nl.astraeus.vst.chip.audio
import nl.astraeus.vst.chip.AudioContext
object AudioContextHandler {
val audioContext: dynamic = AudioContext()
}

View File

@@ -1,14 +0,0 @@
package nl.astraeus.vst.chip.audio
import org.w3c.dom.MessageEvent
object VstChipWorklet : AudioNode(
"vst-chip-worklet.js",
"vst-chip-processor"
) {
override fun onMessage(message: MessageEvent) {
console.log("Message from worklet: ", message)
}
}

View File

@@ -1,273 +0,0 @@
package nl.astraeus.vst.chip.view
import daw.style.Css
import daw.style.Css.defineCss
import daw.style.Css.noTextSelect
import daw.style.CssId
import daw.style.CssName
import daw.style.hover
import kotlinx.browser.window
import kotlinx.html.*
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction
import kotlinx.html.js.onInputFunction
import nl.astraeus.css.properties.*
import nl.astraeus.css.style.cls
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.midi.Midi
import org.khronos.webgl.Uint8Array
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSelectElement
object MainView : Komponent() {
private var messages: MutableList<String> = ArrayList()
private var started = false
init {
MainViewCss
}
fun addMessage(message: String) {
messages.add(message)
while (messages.size > 10) {
messages.removeAt(0)
}
requestUpdate()
}
override fun HtmlBuilder.render() {
div(MainViewCss.MainDivCss.name) {
if (!started) {
div(MainViewCss.StartSplashCss.name) {
div(MainViewCss.StartBoxCss.name) {
div(MainViewCss.StartButtonCss.name) {
+"START"
onClickFunction = {
started = true
VstChipWorklet.create {
requestUpdate()
}
}
}
}
}
}
h1 {
+"VST Chip"
}
div {
span {
+"Midi input: "
select {
option {
+"None"
value = "none"
}
for (mi in Midi.inputs) {
option {
+mi.name
value = mi.id
}
}
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
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") {
//
}
}
}
}
}
span {
+"channel:"
input {
type = InputType.number
value = Midi.inputChannel.toString()
onInputFunction = { event ->
val target = event.target as HTMLInputElement
Midi.inputChannel = target.value.toInt()
println("onInput channel: ${Midi.inputChannel}")
VstChipWorklet.postMessage("set_channel\n${Midi.inputChannel}")
}
}
}
}
div {
span {
+"Midi output: "
select {
option {
+"None"
value = "none"
}
for (mi in Midi.outputs) {
option {
+mi.name
value = mi.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)
}
}
}
}
}
span {
+"channel:"
input {
type = InputType.number
value = Midi.outputChannel.toString()
onInputFunction = { event ->
val target = event.target as HTMLInputElement
Midi.outputChannel = target.value.toInt()
}
}
}
}
div(MainViewCss.ButtonCss.name) {
+"Send note on to output"
onClickFunction = {
val data = Uint8Array(
arrayOf(
0x90.toByte(),
0x3c.toByte(),
0x70.toByte()
)
)
Midi.send(data, window.performance.now() + 1000)
Midi.send(data, window.performance.now() + 2000)
}
}
div(MainViewCss.ButtonCss.name) {
+"Send note off to output"
onClickFunction = {
val data = Uint8Array(
arrayOf(
0x90.toByte(),
0x3c.toByte(),
0x0.toByte(),
)
)
Midi.send(data)
}
}
}
}
object MainViewCss : CssId("main") {
object MainDivCss : CssName()
object ActiveCss : CssName()
object ButtonCss : CssName()
object NoteBarCss : CssName()
object StartSplashCss : CssName()
object StartBoxCss : CssName()
object StartButtonCss : CssName()
init {
defineCss {
select("*") {
select("*:before") {
select("*:after") {
boxSizing(BoxSizing.borderBox)
}
}
}
select("html", "body") {
margin(0.px)
padding(0.px)
height(100.prc)
}
select("html", "body") {
backgroundColor(Css.currentStyle.mainBackgroundColor)
color(Css.currentStyle.mainFontColor)
fontFamily("JetbrainsMono, monospace")
fontSize(14.px)
fontWeight(FontWeight.bold)
//transition()
noTextSelect()
}
select(cls(ButtonCss)) {
margin(1.rem)
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
borderColor(Css.currentStyle.buttonBorderColor)
borderWidth(Css.currentStyle.buttonBorderWidth)
color(Css.currentStyle.mainFontColor)
hover {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover())
}
}
select(cls(ActiveCss)) {
//backgroundColor(Css.currentStyle.selectedBackgroundColor)
}
select(cls(NoteBarCss)) {
minHeight(4.rem)
}
select(cls(MainDivCss)) {
margin(1.rem)
}
select("select") {
plain("appearance", "none")
border("0")
outline("0")
width(20.rem)
padding(0.5.rem, 2.rem, 0.5.rem, 0.5.rem)
backgroundImage("url('https://upload.wikimedia.org/wikipedia/commons/9/9d/Caret_down_font_awesome_whitevariation.svg')")
background("right 0.8em center/1.4em")
backgroundColor(Css.currentStyle.inputBackgroundColor)
//color(Css.currentStyle.entryFontColor)
borderRadius(0.25.em)
}
select(cls(StartSplashCss)) {
position(Position.fixed)
left(0.px)
top(0.px)
width(100.vw)
height(100.vh)
zIndex(100)
backgroundColor(hsla(32, 0, 50, 0.6))
select(cls(StartBoxCss)) {
position(Position.relative)
left(25.vw)
top(25.vh)
width(50.vw)
height(50.vh)
backgroundColor(hsla(0, 0, 50, 0.25))
select(cls(StartButtonCss)) {
position(Position.absolute)
left(50.prc)
top(50.prc)
transform(Transform("translate(-50%, -50%)"))
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
cursor("pointer")
}
}
}
}
}
}
}

View File

@@ -1,115 +0,0 @@
package daw.style
import kotlinx.browser.document
import nl.astraeus.css.properties.*
import nl.astraeus.css.style
import nl.astraeus.css.style.ConditionalStyle
import nl.astraeus.css.style.Style
class StyleDefinition(
val mainFontColor: Color = hsla(178, 70, 55, 1.0),
val mainBackgroundColor: Color = hsl(239, 50, 10),
//val entryFontColor: Color = hsl(Css.mainFontColorNumber, 70, 55),
val inputBackgroundColor : Color = mainBackgroundColor.lighten(15),
val buttonBackgroundColor : Color = mainBackgroundColor.lighten(15),
val buttonBorderColor : Color = mainFontColor.changeAlpha(0.25),
val buttonBorderWidth : Measurement = 1.px,
)
object NoTextSelectCls : CssName("no-text-select")
object SelectedCls : CssName("selected")
object ActiveCls : CssName("active")
fun Color.hover(): Color = if (Css.currentStyle == Css.darkStyle) {
this.lighten(15)
} else {
this.darken(15)
}
object Css {
var minified = false
var dynamicStyles = mutableMapOf<CssId, ConditionalStyle.() -> Unit>()
fun CssId.defineCss(conditionalStyle: ConditionalStyle.() -> Unit) {
check(!dynamicStyles.containsKey(this)) {
"CssId with name ${this.name} already defined!"
}
updateCss(conditionalStyle)
}
private fun CssId.updateCss(conditionalStyle: ConditionalStyle.() -> Unit) {
val elementId = this.description()
var dynamicStyleElement = document.getElementById(elementId)
dynamicStyles[this] = conditionalStyle
if (dynamicStyleElement == null) {
dynamicStyleElement = document.createElement("style")
dynamicStyleElement.id = elementId
document.head?.append(dynamicStyleElement)
}
val css = style(conditionalStyle)
dynamicStyleElement.innerHTML = css.generateCss(minified = false)
}
val darkStyle = StyleDefinition(
)
val lightStyle = StyleDefinition(
mainBackgroundColor = hsl(239+180, 50, 15),
)
var currentStyle: StyleDefinition = darkStyle
fun updateStyle() {
for ((cssId, dynStyle) in dynamicStyles) {
cssId.apply {
updateCss(dynStyle)
}
}
}
fun switchLayout() {
currentStyle = if (currentStyle == darkStyle) {
lightStyle
} else {
darkStyle
}
updateStyle()
}
fun Style.transition() {
transition("all 0.5s ease")
}
fun Style.noTextSelect() {
plain("-webkit-touch-callout", "none")
plain("-webkit-user-select", "none")
plain("-moz-user-select", "none")
plain("-ms-user-select", "none")
userSelect(UserSelect.none)
select("::selection") {
background("none")
}
}
object GenericCss : CssId("generic") {
init {
fun generateStyle(): String {
val css = style {
}
return css.generateCss(minified = minified)
}
}
}
}

View File

@@ -1,65 +0,0 @@
package daw.style
import nl.astraeus.css.style.DescriptionProvider
import nl.astraeus.css.style.cls
private val CAPITAL_LETTER = Regex("[A-Z]")
fun String.hyphenize(): String =
replace(CAPITAL_LETTER) {
"-${it.value.lowercase()}"
}
private val shortId = false
private var nextCssId = 1
private fun nextShortId(): String {
var id = nextCssId++
val result = StringBuilder()
while(id > 0) {
val ch = ((id % 26) + 'a'.code).toChar()
result.append(ch)
id /= 26
}
return result.toString()
}
open class CssName(name: String? = null) : DescriptionProvider {
val name: String = if (shortId) {
nextShortId()
} else if (name != null) {
"daw-$name"
} else {
"daw${this::class.simpleName?.hyphenize() ?: this::class}"
}
override fun description() = name
}
fun CssName.cls() : DescriptionProvider = cls(this)
open class CssId(name: String) : DescriptionProvider {
val name: String = if (shortId) {
nextShortId()
} else {
"daw-$name-css"
}
override fun description() = name
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is CssId) return false
if (name != other.name) return false
return true
}
override fun hashCode(): Int {
return name.hashCode()
}
}

View File

@@ -1,4 +1,4 @@
package nl.astraeus.vst.chip
package nl.astraeus.vst.string
external class AudioContext {
var sampleRate: Int

View File

@@ -0,0 +1,24 @@
package nl.astraeus.vst.string
import kotlinx.browser.document
import nl.astraeus.komp.Komponent
import nl.astraeus.komp.UnsafeMode
import nl.astraeus.vst.string.logger.log
import nl.astraeus.vst.string.midi.Midi
import nl.astraeus.vst.string.view.MainView
import nl.astraeus.vst.string.ws.WebsocketClient
import nl.astraeus.vst.ui.css.CssSettings
fun main() {
CssSettings.shortId = false
CssSettings.preFix = "vst-chip"
Komponent.unsafeMode = UnsafeMode.UNSAFE_SVG_ONLY
Komponent.create(document.body!!, MainView)
Midi.start()
WebsocketClient.connect {
log.debug { "Connected to server" }
}
}

View File

@@ -0,0 +1,10 @@
package nl.astraeus.vst.string.audio
import nl.astraeus.vst.string.AudioContext
object AudioContextHandler {
val audioContext: dynamic = AudioContext()
}

View File

@@ -1,8 +1,8 @@
package nl.astraeus.vst.chip.audio
package nl.astraeus.vst.string.audio
import nl.astraeus.vst.chip.AudioWorkletNode
import nl.astraeus.vst.chip.AudioWorkletNodeParameters
import nl.astraeus.vst.chip.audio.AudioContextHandler.audioContext
import nl.astraeus.vst.string.AudioWorkletNode
import nl.astraeus.vst.string.AudioWorkletNodeParameters
import nl.astraeus.vst.string.audio.AudioContextHandler.audioContext
import org.w3c.dom.MessageEvent
import org.w3c.dom.MessagePort

View File

@@ -0,0 +1,123 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.string.audio
import nl.astraeus.vst.string.PatchDTO
import nl.astraeus.vst.string.view.MainView
import nl.astraeus.vst.string.view.WaveformView
import nl.astraeus.vst.ui.util.uInt8ArrayOf
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
import org.w3c.dom.MessageEvent
import kotlin.experimental.and
object VstStringWorklet : AudioNode(
"/vst-string-worklet.js",
"vst-string-processor"
) {
var midiChannel = 0
set(value) {
check(value in 0..15) {
"Midi channel must be between 0 and 15."
}
field = value
postMessage("set_channel\n${midiChannel}")
}
var volume = 0.75
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 7, (value * 127).toInt())
)
}
var damping = 0.996
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x47, ((value - 0.8) * 127).toInt())
)
}
var delay = 0.0
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4e, (value * 127).toInt())
)
}
var delayDepth = 0.0
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4f, (value * 127).toInt())
)
}
var recording: Float32Array? = null
override fun onMessage(message: MessageEvent) {
//console.log("Message from worklet: ", message)
val data = message.data
if (data is Float32Array) {
this.recording = data
WaveformView.requestUpdate()
}
}
fun postDirectlyToWorklet(msg: Any) {
super.postMessage(msg)
}
override fun postMessage(msg: Any) {
if (msg is Uint8Array) {
if (
msg.length == 3
&& (msg[0] and 0xf == midiChannel.toByte())
&& (msg[0] and 0xf0.toByte() == 0xb0.toByte())
) {
val knob = msg[1]
val value = msg[2]
handleIncomingMidi(knob, value)
} else {
super.postMessage(msg)
}
} else {
super.postMessage(msg)
}
}
private fun handleIncomingMidi(knob: Byte, value: Byte) {
when (knob) {
0x46.toByte() -> {
volume = value / 127.0
MainView.requestUpdate()
}
0x4a.toByte() -> {
damping = value / 127.0
MainView.requestUpdate()
}
}
}
fun load(patch: PatchDTO) {
midiChannel = patch.midiChannel
volume = patch.volume
damping = patch.damping
delay = patch.delay
delayDepth = patch.delayDepth
}
fun save(): PatchDTO {
return PatchDTO(
midiChannel = midiChannel,
volume = volume,
damping = damping,
delay = delay,
delayDepth = delayDepth,
)
}
}

View File

@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.chip.midi
package nl.astraeus.vst.string.midi
import kotlinx.browser.window
import org.khronos.webgl.Uint8Array

View File

@@ -1,8 +1,8 @@
package nl.astraeus.vst.chip.midi
package nl.astraeus.vst.string.midi
import kotlinx.browser.window
import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.view.MainView
import nl.astraeus.vst.string.audio.VstStringWorklet
import nl.astraeus.vst.string.view.MainView
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.get
@@ -37,7 +37,6 @@ external class MIDIOutput {
}
object Midi {
var inputChannel: Int = -1
var outputChannel: Int = -1
var inputs = mutableListOf<MIDIInput>()
@@ -75,6 +74,39 @@ object Midi {
)
}
fun setInput(id: String, name: String = "") {
var selected = inputs.find { it.id == id }
if (selected == null) {
var maxMatchChar = 0
inputs.forEach {
val matchChars = matchChars(it.name, name)
if (matchChars > maxMatchChar) {
selected = it
maxMatchChar = matchChars
}
}
}
setInput(selected)
}
private fun matchChars(str1: String, str2: String): Int {
var result = 0
if (str1.length > str2.length) {
for (ch in str1.toCharArray()) {
if (str2.contains(ch)) {
result++
}
}
} else {
for (ch in str2.toCharArray()) {
if (str1.contains(ch)) {
result++
}
}
}
return result
}
fun setInput(input: MIDIInput?) {
console.log("Setting input", input)
currentInput?.close()
@@ -93,7 +125,7 @@ object Midi {
hex.append(" ")
}
console.log("Midi message:", hex)
VstChipWorklet.postMessage(
VstStringWorklet.postMessage(
message.data
)
}
@@ -110,7 +142,7 @@ object Midi {
currentOutput?.open()
}
fun send(data: Uint8Array, timestamp: dynamic? = null) {
fun send(data: Uint8Array, timestamp: dynamic = null) {
currentOutput?.send(data, timestamp)
}

View File

@@ -0,0 +1,301 @@
@file:OptIn(ExperimentalJsExport::class)
package nl.astraeus.vst.string.view
import kotlinx.html.InputType
import kotlinx.html.div
import kotlinx.html.h1
import kotlinx.html.input
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction
import kotlinx.html.js.onInputFunction
import kotlinx.html.option
import kotlinx.html.select
import kotlinx.html.span
import nl.astraeus.css.properties.AlignItems
import nl.astraeus.css.properties.BoxSizing
import nl.astraeus.css.properties.Display
import nl.astraeus.css.properties.FlexDirection
import nl.astraeus.css.properties.FontWeight
import nl.astraeus.css.properties.JustifyContent
import nl.astraeus.css.properties.Position
import nl.astraeus.css.properties.Transform
import nl.astraeus.css.properties.em
import nl.astraeus.css.properties.hsla
import nl.astraeus.css.properties.prc
import nl.astraeus.css.properties.px
import nl.astraeus.css.properties.rem
import nl.astraeus.css.properties.vh
import nl.astraeus.css.properties.vw
import nl.astraeus.css.style.Style
import nl.astraeus.css.style.cls
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.vst.string.PhysicalString
import nl.astraeus.vst.string.audio.VstStringWorklet
import nl.astraeus.vst.string.audio.VstStringWorklet.midiChannel
import nl.astraeus.vst.string.midi.Midi
import nl.astraeus.vst.string.ws.WebsocketClient
import nl.astraeus.vst.ui.components.ExpKnobComponent
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 nl.astraeus.vst.ui.util.uInt8ArrayOf
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSelectElement
object MainView : Komponent(), CssName {
private var messages: MutableList<String> = ArrayList()
var started = false
val playString = PhysicalStringView(
PhysicalString(
sampleRate = 48000,
damping = 0.996,
)
)
init {
css()
}
fun addMessage(message: String) {
messages.add(message)
while (messages.size > 10) {
messages.removeAt(0)
}
requestUpdate()
}
override fun HtmlBuilder.render() {
div(MainDivCss.name) {
if (!started) {
div(StartSplashCss.name) {
div(StartBoxCss.name) {
div(StartButtonCss.name) {
+"START"
onClickFunction = {
VstStringWorklet.create {
started = true
requestUpdate()
WebsocketClient.send("LOAD\n")
}
}
}
}
}
}
h1 {
+"VST Guitar"
}
div {
span {
+"Midi input: "
select {
option {
+"None"
value = "none"
}
for (mi in Midi.inputs) {
option {
+mi.name
value = mi.id
selected = mi.id == Midi.currentInput?.id
}
}
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
if (target.value == "none") {
Midi.setInput(null)
} else {
Midi.setInput(target.value)
}
}
}
}
span {
+"channel:"
input {
type = InputType.number
value = midiChannel.toString()
onInputFunction = { event ->
val target = event.target as HTMLInputElement
println("onInput channel: $target")
VstStringWorklet.midiChannel = target.value.toInt()
}
}
}
}
div {
span(ButtonBarCss.name) {
+"SAVE"
onClickFunction = {
val patch = VstStringWorklet.save().copy(
midiId = Midi.currentInput?.id ?: "",
midiName = Midi.currentInput?.name ?: ""
)
WebsocketClient.send("SAVE\n${JSON.stringify(patch)}")
}
}
span(ButtonBarCss.name) {
+"STOP"
onClickFunction = {
VstStringWorklet.postDirectlyToWorklet(
uInt8ArrayOf(0xb0 + midiChannel, 123, 0)
)
}
}
}
div(ControlsCss.name) {
include(
ExpKnobComponent(
value = VstStringWorklet.volume,
label = "Volume",
minValue = 0.005,
maxValue = 1.0,
step = 5.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstStringWorklet.volume = value
}
)
}
include(WaveformView)
include(playString)
}
}
object MainDivCss : CssName
object ActiveCss : CssName
object ButtonCss : CssName
object ButtonBarCss : CssName
object SelectedCss : CssName
object NoteBarCss : CssName
object StartSplashCss : CssName
object StartBoxCss : CssName
object StartButtonCss : CssName
object ControlsCss : CssName
private fun css() {
defineCss {
select("*") {
select("*:before") {
select("*:after") {
boxSizing(BoxSizing.borderBox)
}
}
}
select("html", "body") {
margin(0.px)
padding(0.px)
height(100.prc)
}
select("html", "body") {
backgroundColor(Css.currentStyle.mainBackgroundColor)
color(Css.currentStyle.mainFontColor)
fontFamily("JetbrainsMono, monospace")
fontSize(14.px)
fontWeight(FontWeight.bold)
//transition()
noTextSelect()
}
select("input", "textarea") {
backgroundColor(Css.currentStyle.inputBackgroundColor)
color(Css.currentStyle.mainFontColor)
border("none")
}
select(cls(ButtonCss)) {
margin(1.rem)
commonButton()
}
select(cls(ButtonBarCss)) {
margin(1.rem, 0.px)
commonButton()
}
select(cls(ActiveCss)) {
//backgroundColor(Css.currentStyle.selectedBackgroundColor)
}
select(cls(NoteBarCss)) {
minHeight(4.rem)
}
select(cls(MainDivCss)) {
margin(1.rem)
}
select("select") {
plain("appearance", "none")
border("0")
outline("0")
width(20.rem)
padding(0.5.rem, 2.rem, 0.5.rem, 0.5.rem)
backgroundImage("url('https://upload.wikimedia.org/wikipedia/commons/9/9d/Caret_down_font_awesome_whitevariation.svg')")
background("right 0.8em center/1.4em")
backgroundColor(Css.currentStyle.inputBackgroundColor)
color(Css.currentStyle.mainFontColor)
borderRadius(0.25.em)
}
select(cls(StartSplashCss)) {
position(Position.fixed)
left(0.px)
top(0.px)
width(100.vw)
height(100.vh)
zIndex(100)
backgroundColor(hsla(32, 0, 5, 0.65))
select(cls(StartBoxCss)) {
position(Position.relative)
left(25.vw)
top(25.vh)
width(50.vw)
height(50.vh)
backgroundColor(hsla(239, 50, 10, 1.0))
borderColor(Css.currentStyle.mainFontColor)
borderWidth(2.px)
select(cls(StartButtonCss)) {
position(Position.absolute)
left(50.prc)
top(50.prc)
transform(Transform("translate(-50%, -50%)"))
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
cursor("pointer")
}
}
}
select(ControlsCss.cls()) {
display(Display.flex)
flexDirection(FlexDirection.row)
justifyContent(JustifyContent.flexStart)
alignItems(AlignItems.center)
margin(1.rem)
padding(1.rem)
backgroundColor(Css.currentStyle.mainBackgroundColor)
}
}
}
private fun Style.commonButton() {
display(Display.inlineBlock)
padding(1.rem)
backgroundColor(Css.currentStyle.buttonBackgroundColor)
borderColor(Css.currentStyle.buttonBorderColor)
borderWidth(Css.currentStyle.buttonBorderWidth)
color(Css.currentStyle.mainFontColor)
hover {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover())
}
and(SelectedCss.cls()) {
backgroundColor(Css.currentStyle.buttonBackgroundColor.hover().hover().hover())
}
}
}

View File

@@ -0,0 +1,109 @@
package nl.astraeus.vst.string.view
import kotlinx.browser.window
import kotlinx.html.canvas
import kotlinx.html.div
import kotlinx.html.js.onClickFunction
import kotlinx.html.span
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.komp.currentElement
import nl.astraeus.vst.Note
import nl.astraeus.vst.string.PhysicalString
import nl.astraeus.vst.string.audio.VstStringWorklet
import nl.astraeus.vst.string.view.MainView.ControlsCss
import nl.astraeus.vst.ui.components.KnobComponent
import nl.astraeus.vst.util.formatDouble
import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement
class PhysicalStringView(
val string: PhysicalString
) : Komponent() {
var context: CanvasRenderingContext2D? = null
var interval: Int = -1
var lastUpdateTime: Double = window.performance.now()
init {
window.requestAnimationFrame(::onAnimationFrame)
interval = window.setInterval({
if (context?.canvas?.isConnected == true) {
val now: Double = window.performance.now()
val time = now - lastUpdateTime
lastUpdateTime = now
string.update(time)
} else {
window.clearInterval(interval)
}
}, 1)
}
private fun onAnimationFrame(time: Double) {
if (MainView.started) {
draw()
}
window.requestAnimationFrame(::onAnimationFrame)
}
override fun HtmlBuilder.render() {
div {
div(ControlsCss.name) {
include(
KnobComponent(
value = VstStringWorklet.damping,
label = "Damping",
minValue = 0.8,
maxValue = 1.0,
step = 0.2 / 127.0,
width = 100,
height = 120,
renderer = { formatDouble(it, 3) }
) { value ->
VstStringWorklet.damping = value
}
)
}
div {
span {
+"Play C3"
onClickFunction = {
string.pluck(Note.C3, 1.0)
}
}
span {
+"Play C4"
onClickFunction = {
string.pluck(Note.C4, 1.0)
}
}
}
canvas {
width = "1000"
height = "400"
context = (currentElement() as? HTMLCanvasElement)?.getContext("2d") as? CanvasRenderingContext2D
}
}
}
private fun draw() {
val ctx = context
if (ctx != null) {
val width = ctx.canvas.width.toDouble()
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 = width / string.length
ctx.beginPath()
ctx.strokeStyle = "rgba(0, 255, 255, 0.5)"
for (i in 0 until string.length) {
ctx.moveTo(i * step, halfHeight)
ctx.lineTo(i * step, halfHeight + string.buffer[i] * halfHeight)
}
ctx.stroke()
}
}
}

View File

@@ -0,0 +1,56 @@
package nl.astraeus.vst.string.view
import kotlinx.browser.window
import kotlinx.html.canvas
import kotlinx.html.div
import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.komp.currentElement
import nl.astraeus.vst.string.audio.VstStringWorklet
import org.khronos.webgl.get
import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement
object WaveformView : Komponent() {
init {
window.requestAnimationFrame(::onAnimationFrame)
}
fun onAnimationFrame(time: Double) {
if (MainView.started) {
VstStringWorklet.postMessage("start_recording")
}
window.requestAnimationFrame(::onAnimationFrame)
}
override fun HtmlBuilder.render() {
div {
if (VstStringWorklet.recording != null) {
canvas {
width = "1000"
height = "400"
val ctx = (currentElement() as? HTMLCanvasElement)?.getContext("2d") as? CanvasRenderingContext2D
val data = VstStringWorklet.recording
if (ctx != null && data != null) {
val width = ctx.canvas.width.toDouble()
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(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)
}
ctx.stroke()
}
}
}
}
}
}

View File

@@ -0,0 +1,120 @@
package nl.astraeus.vst.string.ws
import kotlinx.browser.window
import nl.astraeus.vst.string.PatchDTO
import nl.astraeus.vst.string.audio.VstStringWorklet
import nl.astraeus.vst.string.midi.Midi
import nl.astraeus.vst.string.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}:${window.location.port}/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<PatchDTO>(patchJson)
Midi.setInput(patch.midiId, patch.midiName)
VstStringWorklet.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)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
package nl.astraeus.vst.chip

View File

@@ -1,20 +0,0 @@
package nl.astraeus.vst.chip
import io.undertow.Undertow
import io.undertow.UndertowOptions
fun main() {
Thread.setDefaultUncaughtExceptionHandler { _, e ->
e.printStackTrace()
}
val server = Undertow.builder()
.addHttpListener(Settings.port, "localhost")
.setIoThreads(4)
.setHandler(RequestHandler)
.setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 1000)
.build()
println("Starting server at port ${Settings.port}...")
server?.start()
}

View File

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

View File

@@ -0,0 +1,16 @@
package nl.astraeus.vst.string
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()
}

View File

@@ -0,0 +1,49 @@
package nl.astraeus.vst.string
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.string.db.Database
import nl.astraeus.vst.string.logger.LogLevel
import nl.astraeus.vst.string.logger.Logger
import nl.astraeus.vst.string.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(sessionHandler)
.setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 1000)
.build()
println("Starting server at port ${Settings.port}...")
server?.start()
}

View File

@@ -1,4 +1,4 @@
package nl.astraeus.vst.chip
package nl.astraeus.vst.string
import java.io.File
import java.io.FileInputStream
@@ -6,7 +6,7 @@ import java.util.*
object Settings {
var runningAsRoot: Boolean = false
var port = 9000
var port = 9004
var sslPort = 8443
var connectionTimeout = 30000

View File

@@ -0,0 +1,170 @@
package nl.astraeus.vst.string.db
import kotlinx.datetime.Instant
import nl.astraeus.vst.string.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<T : Entity>(
val sql: String,
val prepareParameters: T.(PreparedStatement) -> Unit
)
data class SqlQuery<T : Entity>(
val sql: String,
val resultMapper: (ResultSet) -> T
)
abstract class QueryProvider<T : Entity> {
abstract val tableName: String
open val idQuery: String
get() = "SELECT * FROM $tableName WHERE ID = ?"
abstract val resultSetMapper: (ResultSet) -> T
open val find: SqlQuery<T>
get() = SqlQuery(
idQuery,
resultSetMapper
)
abstract val insert: SqlStatement<T>
abstract val update: SqlStatement<T>
open val delete: SqlStatement<T>
get() = SqlStatement(
"DELETE FROM $tableName WHERE ID = ?"
) { ps ->
ps.setLong(1, getPK()[0] as Long)
}
}
abstract class BaseDao<T : Entity> {
abstract val queryProvider: QueryProvider<T>
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<T>,
prepareParameters: (PreparedStatement) -> Unit,
): List<T> {
return transaction { con ->
log.debug { "Executing query [$label] - [${statement.sql}]" }
val result = mutableListOf<T>()
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<T>,
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<T>,
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
}
}

View File

@@ -0,0 +1,99 @@
package nl.astraeus.vst.string.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<Connection>()
fun <T> 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
*/
}

View File

@@ -0,0 +1,16 @@
package nl.astraeus.vst.string.db
interface Entity {
fun getPK(): Array<Any>
fun setPK(pks: Array<Any>)
}
interface EntityId : Entity {
var id: Long
override fun getPK(): Array<Any> = arrayOf(id)
override fun setPK(pks: Array<Any>) {
id = pks[0] as Long
}
}

View File

@@ -0,0 +1,106 @@
package nl.astraeus.vst.string.db
import nl.astraeus.vst.string.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>(
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..<DATABASE_MIGRATIONS.size) {
executeMigration(index)
}
}
}
}
}
} catch (e: SQLException) {
if (databaseVersionTableCreated.compareAndSet(false, true)) {
executeMigration(0)
updateDatabaseIfNeeded()
} else {
throw e
}
}
}
private fun executeMigration(index: Int) {
transaction { con ->
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()
}
}
}
}

View File

@@ -0,0 +1,31 @@
package nl.astraeus.vst.string.db
object PatchDao : BaseDao<PatchEntity>() {
override val queryProvider: QueryProvider<PatchEntity>
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()
}

View File

@@ -0,0 +1,12 @@
package nl.astraeus.vst.string.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

View File

@@ -0,0 +1,64 @@
package nl.astraeus.vst.string.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<PatchEntity>() {
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<PatchEntity>
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<PatchEntity>
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)
}
}

View File

@@ -0,0 +1,42 @@
package nl.astraeus.vst.string.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 String" }
}
body {
script {
type = "application/javascript"
src = "/vst-string-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()
}

View File

@@ -0,0 +1,128 @@
package nl.astraeus.vst.string.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.string.db.PatchDao
import nl.astraeus.vst.string.db.PatchEntity
import nl.astraeus.vst.string.db.transaction
import nl.astraeus.vst.string.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)
}
}

View File

@@ -0,0 +1,5 @@
package nl.astraeus.vst.string.web
class VstSession(
val patchId: String
)

View File

@@ -1,6 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<body>
<script type="application/javascript" src="vst-chip-worklet-ui.js"></script>
</body>
</html>