Save patch

This commit is contained in:
2024-06-30 20:32:43 +02:00
parent 194857d687
commit 976328ed69
24 changed files with 1155 additions and 162 deletions

2
.gitignore vendored
View File

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

View File

@@ -2,8 +2,10 @@
package nl.astraeus.vst.chip package nl.astraeus.vst.chip
import nl.astraeus.vst.ADSR
import nl.astraeus.vst.AudioWorkletProcessor import nl.astraeus.vst.AudioWorkletProcessor
import nl.astraeus.vst.Note import nl.astraeus.vst.Note
import nl.astraeus.vst.currentTime
import nl.astraeus.vst.registerProcessor import nl.astraeus.vst.registerProcessor
import nl.astraeus.vst.sampleRate import nl.astraeus.vst.sampleRate
import org.khronos.webgl.Float32Array import org.khronos.webgl.Float32Array
@@ -19,14 +21,6 @@ import kotlin.math.sin
val POLYPHONICS = 10 val POLYPHONICS = 10
val PI2 = PI * 2 val PI2 = PI * 2
@ExperimentalJsExport
@JsExport
enum class NoteState {
ON,
RELEASED,
OFF
}
@ExperimentalJsExport @ExperimentalJsExport
@JsExport @JsExport
class PlayingNote( class PlayingNote(
@@ -35,17 +29,15 @@ class PlayingNote(
) { ) {
fun retrigger(velocity: Int) { fun retrigger(velocity: Int) {
this.velocity = velocity this.velocity = velocity
state = NoteState.ON
sample = 0 sample = 0
attackSamples = 2500 noteStart = currentTime
releaseSamples = 10000 noteRelease = null
} }
var state = NoteState.OFF var noteStart = currentTime
var noteRelease: Double? = null
var cycleOffset = 0.0 var cycleOffset = 0.0
var sample = 0 var sample = 0
var attackSamples = 2500
var releaseSamples = 10000
var actualVolume = 0f var actualVolume = 0f
} }
@@ -68,11 +60,8 @@ enum class RecordingState {
@JsExport @JsExport
class VstChipProcessor : AudioWorkletProcessor() { class VstChipProcessor : AudioWorkletProcessor() {
var midiChannel = 0 var midiChannel = 0
val notes = Array(POLYPHONICS) { val notes = Array<PlayingNote?>(POLYPHONICS) { null }
PlayingNote(
0
)
}
var waveform = Waveform.SINE.ordinal var waveform = Waveform.SINE.ordinal
var volume = 0.75f var volume = 0.75f
var dutyCycle = 0.5 var dutyCycle = 0.5
@@ -80,6 +69,12 @@ class VstChipProcessor : AudioWorkletProcessor() {
var fmAmp = 0.0 var fmAmp = 0.0
var amFreq = 0.0 var amFreq = 0.0
var amAmp = 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 sampleLength = 1 / sampleRate.toDouble()
val recordingBuffer = Float32Array(sampleRate / 60) val recordingBuffer = Float32Array(sampleRate / 60)
@@ -198,27 +193,49 @@ class VstChipProcessor : AudioWorkletProcessor() {
val value = bytes[2] val value = bytes[2]
when (knob) { when (knob) {
0x46 -> { 7 -> {
volume = value / 127f volume = value / 127f
} }
0x4a -> {
dutyCycle = value / 127.0
}
0x4b -> {
fmFreq = value / 127.0
}
0x4c -> {
fmAmp = value / 127.0
}
0x47 -> { 0x47 -> {
dutyCycle = value / 127.0
}
0x4a -> {
fmFreq = value / 127.0
}
0x4b -> {
fmAmp = value / 127.0
}
0x4c -> {
amFreq = value / 127.0 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 -> { 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) { private fun noteOn(note: Int, velocity: Int) {
for (i in 0 until POLYPHONICS) { for (i in 0 until POLYPHONICS) {
if (notes[i].note == note) { if (notes[i]?.note == note) {
notes[i].retrigger(velocity) notes[i]?.retrigger(velocity)
return return
} }
} }
for (i in 0 until POLYPHONICS) { for (i in 0 until POLYPHONICS) {
if (notes[i].state == NoteState.OFF) { if (notes[i] == null) {
notes[i] = PlayingNote( notes[i] = PlayingNote(
note, note,
velocity velocity
) )
notes[i].state = NoteState.ON
val n = Note.fromMidi(note)
//console.log("Playing note: ${n.sharp} (${n.freq})")
break break
} }
} }
@@ -260,8 +273,8 @@ class VstChipProcessor : AudioWorkletProcessor() {
private fun noteOff(note: Int) { private fun noteOff(note: Int) {
for (i in 0 until POLYPHONICS) { for (i in 0 until POLYPHONICS) {
if (notes[i].note == note && notes[i].state == NoteState.ON) { if (notes[i]?.note == note) {
notes[i].state = NoteState.RELEASED notes[i]?.noteRelease = currentTime
break break
} }
} }
@@ -279,7 +292,7 @@ class VstChipProcessor : AudioWorkletProcessor() {
var lowestNote = 200 var lowestNote = 200
for (note in notes) { for (note in notes) {
if (note.state != NoteState.OFF) { if (note != null) {
lowestNote = min(lowestNote, note.note) lowestNote = min(lowestNote, note.note)
} }
} }
@@ -289,23 +302,25 @@ class VstChipProcessor : AudioWorkletProcessor() {
recordingStart = 0 recordingStart = 0
} }
for (note in notes) { for ((index, note) in notes.withIndex()) {
if (note.state != NoteState.OFF) { if (note != null) {
val sampleDelta = Note.fromMidi(note.note).sampleDelta val sampleDelta = Note.fromMidi(note.note).sampleDelta
for (i in 0 until samples) { for (i in 0 until samples) {
var targetVolume = note.velocity / 127f var targetVolume = note.velocity / 127f
if (note.state == NoteState.ON && note.sample < note.attackSamples) { targetVolume *= ADSR.calculate(
note.attackSamples-- attack,
targetVolume *= ( 1f - (note.attackSamples / 2500f)) decay,
} else if (note.state == NoteState.RELEASED) { sustain,
note.releaseSamples-- release,
targetVolume *= (note.releaseSamples / 10000f) note.noteStart,
} currentTime,
note.noteRelease
).toFloat()
note.actualVolume += (targetVolume - note.actualVolume) * 0.0001f note.actualVolume += (targetVolume - note.actualVolume) * 0.0001f
if (note.state == NoteState.RELEASED && note.actualVolume <= 0) { if (note.noteRelease != null && note.actualVolume <= 0.01) {
note.state = NoteState.OFF notes[index] = null
} }
var cycleOffset = note.cycleOffset var cycleOffset = note.cycleOffset

View File

@@ -38,6 +38,7 @@ kotlin {
implementation(project(":common")) implementation(project(":common"))
//base //base
api("nl.astraeus:kotlin-css-generator:1.0.7") api("nl.astraeus:kotlin-css-generator:1.0.7")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
} }
} }
val jsMain by getting { val jsMain by getting {
@@ -57,8 +58,13 @@ kotlin {
//base //base
implementation("io.undertow:undertow-core:2.3.14.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.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") implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
} }
} }

View File

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

View File

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

View File

@@ -1,15 +1,13 @@
package nl.astraeus.vst.chip package nl.astraeus.vst.chip
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.browser.window
import nl.astraeus.komp.Komponent import nl.astraeus.komp.Komponent
import nl.astraeus.komp.UnsafeMode 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.Midi
import nl.astraeus.vst.chip.midi.MidiMessage
import nl.astraeus.vst.chip.view.MainView import nl.astraeus.vst.chip.view.MainView
import nl.astraeus.vst.chip.ws.WebsocketClient
import nl.astraeus.vst.ui.css.CssSettings import nl.astraeus.vst.ui.css.CssSettings
import org.khronos.webgl.Uint8Array
fun main() { fun main() {
CssSettings.shortId = false CssSettings.shortId = false
@@ -20,16 +18,7 @@ fun main() {
Midi.start() Midi.start()
console.log("Performance", window.performance) WebsocketClient.connect {
Broadcaster.getChannel(0).postMessage( log.debug { "Connected to server" }
MidiMessage( }
Uint8Array(arrayOf(0x80.toByte(), 60, 60)),
window.performance.now()
)
)
window.setInterval({
Broadcaster.sync()
}, 1000)
} }

View File

@@ -1,5 +1,6 @@
package nl.astraeus.vst.chip.audio 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.MainView
import nl.astraeus.vst.chip.view.WaveformView import nl.astraeus.vst.chip.view.WaveformView
import nl.astraeus.vst.ui.util.uInt8ArrayOf import nl.astraeus.vst.ui.util.uInt8ArrayOf
@@ -10,7 +11,7 @@ import org.w3c.dom.MessageEvent
import kotlin.experimental.and import kotlin.experimental.and
object VstChipWorklet : AudioNode( object VstChipWorklet : AudioNode(
"vst-chip-worklet.js", "/vst-chip-worklet.js",
"vst-chip-processor" "vst-chip-processor"
) { ) {
var waveform: Int = 0 var waveform: Int = 0
@@ -30,44 +31,74 @@ object VstChipWorklet : AudioNode(
set(value) { set(value) {
field = value field = value
super.postMessage( super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x46, (value * 127).toInt()) uInt8ArrayOf(0xb0 + midiChannel, 7, (value * 127).toInt())
) )
} }
var dutyCycle = 0.5 var dutyCycle = 0.5
set(value) { set(value) {
field = value field = value
super.postMessage( super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4a, (value * 127).toInt()) uInt8ArrayOf(0xb0 + midiChannel, 0x47, (value * 127).toInt())
) )
} }
var fmModFreq = 0.0 var fmModFreq = 0.0
set(value) { set(value) {
field = value field = value
super.postMessage( super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4b, (value * 127).toInt()) uInt8ArrayOf(0xb0 + midiChannel, 0x4a, (value * 127).toInt())
) )
} }
var fmModAmp = 0.0 var fmModAmp = 0.0
set(value) { set(value) {
field = value field = value
super.postMessage( super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4c, (value * 127).toInt()) uInt8ArrayOf(0xb0 + midiChannel, 0x4b, (value * 127).toInt())
) )
} }
var amModFreq = 0.0 var amModFreq = 0.0
set(value) { set(value) {
field = value field = value
super.postMessage( super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x47, (value * 127).toInt()) uInt8ArrayOf(0xb0 + midiChannel, 0x4c, (value * 127).toInt())
) )
} }
var amModAmp = 0.0 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) { set(value) {
field = value field = value
super.postMessage( super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x48, (value * 127).toInt()) uInt8ArrayOf(0xb0 + midiChannel, 0x48, (value * 127).toInt())
) )
} }
var recording: Float32Array? = null var recording: Float32Array? = null
override fun onMessage(message: MessageEvent) { override fun onMessage(message: MessageEvent) {
@@ -80,6 +111,10 @@ object VstChipWorklet : AudioNode(
} }
} }
fun postDirectlyToWorklet(msg: Any) {
super.postMessage(msg)
}
override fun postMessage(msg: Any) { override fun postMessage(msg: Any) {
if (msg is Uint8Array) { if (msg is Uint8Array) {
if ( 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
)
}
} }

View File

@@ -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?) { fun setInput(input: MIDIInput?) {
console.log("Setting input", input) console.log("Setting input", input)
currentInput?.close() currentInput?.close()

View File

@@ -34,14 +34,16 @@ import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent import nl.astraeus.komp.Komponent
import nl.astraeus.komp.currentElement import nl.astraeus.komp.currentElement
import nl.astraeus.vst.chip.audio.VstChipWorklet 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.midi.Midi
import nl.astraeus.vst.chip.ws.WebsocketClient
import nl.astraeus.vst.ui.components.KnobComponent import nl.astraeus.vst.ui.components.KnobComponent
import nl.astraeus.vst.ui.css.Css import nl.astraeus.vst.ui.css.Css
import nl.astraeus.vst.ui.css.Css.defineCss import nl.astraeus.vst.ui.css.Css.defineCss
import nl.astraeus.vst.ui.css.Css.noTextSelect import nl.astraeus.vst.ui.css.Css.noTextSelect
import nl.astraeus.vst.ui.css.CssName import nl.astraeus.vst.ui.css.CssName
import nl.astraeus.vst.ui.css.hover 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.khronos.webgl.get
import org.w3c.dom.CanvasRenderingContext2D import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLCanvasElement
@@ -75,10 +77,11 @@ object WaveformView: Komponent() {
val height = ctx.canvas.height.toDouble() val height = ctx.canvas.height.toDouble()
val halfHeight = height / 2.0 val halfHeight = height / 2.0
ctx.lineWidth = 2.0
ctx.clearRect(0.0, 0.0, width, height) ctx.clearRect(0.0, 0.0, width, height)
val step = 1000.0 / data.length val step = 1000.0 / data.length
ctx.beginPath() ctx.beginPath()
ctx.strokeStyle = "rgba(255, 255, 255, 0.5)" ctx.strokeStyle = "rgba(0, 255, 255, 0.5)"
ctx.moveTo(0.0, halfHeight) ctx.moveTo(0.0, halfHeight)
for (i in 0 until data.length) { for (i in 0 until data.length) {
ctx.lineTo(i * step, halfHeight - data[i] * halfHeight) ctx.lineTo(i * step, halfHeight - data[i] * halfHeight)
@@ -119,6 +122,7 @@ object MainView : Komponent(), CssName {
VstChipWorklet.create { VstChipWorklet.create {
started = true started = true
requestUpdate() requestUpdate()
WebsocketClient.send("LOAD\n")
} }
} }
} }
@@ -140,6 +144,7 @@ object MainView : Komponent(), CssName {
option { option {
+mi.name +mi.name
value = mi.id value = mi.id
selected = mi.id == Midi.currentInput?.id
} }
} }
@@ -148,12 +153,7 @@ object MainView : Komponent(), CssName {
if (target.value == "none") { if (target.value == "none") {
Midi.setInput(null) Midi.setInput(null)
} else { } else {
val selected = Midi.inputs.find { it.id == target.value } Midi.setInput(target.value)
if (selected != null) {
Midi.setInput(selected)
} else if (target.value == "midi-broadcast") {
//
}
} }
} }
} }
@@ -172,71 +172,20 @@ object MainView : Komponent(), CssName {
} }
} }
div { div {
span { span(ButtonBarCss.name) {
+"Midi output: " +"SAVE"
select { onClickFunction = {
option { val patch = VstChipWorklet.save().copy(midiId = Midi.currentInput?.id ?: "")
+"None"
value = "none"
}
for (mi in Midi.outputs) {
option {
+mi.name
value = mi.id
}
}
onChangeFunction = { event -> WebsocketClient.send("SAVE\n${JSON.stringify(patch)}")
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 { span(ButtonBarCss.name) {
+"channel:" +"STOP"
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"
onClickFunction = { onClickFunction = {
val data = Uint8Array( VstChipWorklet.postDirectlyToWorklet(
arrayOf( uInt8ArrayOf(0xb0 + midiChannel, 123, 0)
0x90.toByte(),
0x3c.toByte(),
0x70.toByte()
)
) )
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) include(WaveformView)
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,46 @@
package nl.astraeus.vst.chip package nl.astraeus.vst.chip
import com.zaxxer.hikari.HikariConfig
import io.undertow.Undertow import io.undertow.Undertow
import io.undertow.UndertowOptions 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() { fun main() {
Logger.level = LogLevel.DEBUG
Thread.setDefaultUncaughtExceptionHandler { _, e -> Thread.setDefaultUncaughtExceptionHandler { _, e ->
e.printStackTrace() 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() val server = Undertow.builder()
.addHttpListener(Settings.port, "localhost") .addHttpListener(Settings.port, "localhost")
.setIoThreads(4) .setIoThreads(4)
.setHandler(RequestHandler) .setHandler(sessionHandler)
.setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 1000) .setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 1000)
.build() .build()

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,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<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.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<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.chip.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.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>(
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.chip.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.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

View File

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

View File

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

View File

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