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 ###
.DS_Store
web
/web
.kotlin
.idea

View File

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

View File

@@ -38,6 +38,7 @@ kotlin {
implementation(project(":common"))
//base
api("nl.astraeus:kotlin-css-generator:1.0.7")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
}
}
val jsMain by getting {
@@ -57,8 +58,13 @@ kotlin {
//base
implementation("io.undertow:undertow-core:2.3.14.Final")
implementation("io.undertow:undertow-websockets-jsr:2.3.14.Final")
implementation("org.jboss.xnio:xnio-nio:3.8.16.Final")
implementation("org.xerial:sqlite-jdbc:3.46.0.0")
implementation("com.zaxxer:HikariCP:4.0.3")
implementation("nl.astraeus:simple-jdbc-stats:1.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
}
}

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

View File

@@ -1,5 +1,6 @@
package nl.astraeus.vst.chip.audio
import nl.astraeus.vst.chip.PatchDTO
import nl.astraeus.vst.chip.view.MainView
import nl.astraeus.vst.chip.view.WaveformView
import nl.astraeus.vst.ui.util.uInt8ArrayOf
@@ -10,7 +11,7 @@ import org.w3c.dom.MessageEvent
import kotlin.experimental.and
object VstChipWorklet : AudioNode(
"vst-chip-worklet.js",
"/vst-chip-worklet.js",
"vst-chip-processor"
) {
var waveform: Int = 0
@@ -30,44 +31,74 @@ object VstChipWorklet : AudioNode(
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x46, (value * 127).toInt())
uInt8ArrayOf(0xb0 + midiChannel, 7, (value * 127).toInt())
)
}
var dutyCycle = 0.5
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4a, (value * 127).toInt())
uInt8ArrayOf(0xb0 + midiChannel, 0x47, (value * 127).toInt())
)
}
var fmModFreq = 0.0
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4b, (value * 127).toInt())
uInt8ArrayOf(0xb0 + midiChannel, 0x4a, (value * 127).toInt())
)
}
var fmModAmp = 0.0
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4c, (value * 127).toInt())
uInt8ArrayOf(0xb0 + midiChannel, 0x4b, (value * 127).toInt())
)
}
var amModFreq = 0.0
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x47, (value * 127).toInt())
uInt8ArrayOf(0xb0 + midiChannel, 0x4c, (value * 127).toInt())
)
}
var amModAmp = 0.0
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4d, (value * 127).toInt())
)
}
var attack = 0.1
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x49, (value * 127).toInt())
)
}
var decay = 0.2
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x4b, (value * 127).toInt())
)
}
var sustain = 0.5
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x46, (value * 127).toInt())
)
}
var release = 0.2
set(value) {
field = value
super.postMessage(
uInt8ArrayOf(0xb0 + midiChannel, 0x48, (value * 127).toInt())
)
}
var recording: Float32Array? = null
override fun onMessage(message: MessageEvent) {
@@ -80,6 +111,10 @@ object VstChipWorklet : AudioNode(
}
}
fun postDirectlyToWorklet(msg: Any) {
super.postMessage(msg)
}
override fun postMessage(msg: Any) {
if (msg is Uint8Array) {
if (
@@ -133,4 +168,36 @@ object VstChipWorklet : AudioNode(
}
}
fun load(patch: PatchDTO) {
waveform = patch.waveform
midiChannel = patch.midiChannel
volume = patch.volume
dutyCycle = patch.dutyCycle
fmModFreq = patch.fmModFreq
fmModAmp = patch.fmModAmp
amModFreq = patch.amModFreq
amModAmp = patch.amModAmp
attack = patch.attack
decay = patch.decay
sustain = patch.sustain
release = patch.release
}
fun save(): PatchDTO {
return PatchDTO(
waveform = waveform,
midiChannel = midiChannel,
volume = volume,
dutyCycle = dutyCycle,
fmModFreq = fmModFreq,
fmModAmp = fmModAmp,
amModFreq = amModFreq,
amModAmp = amModAmp,
attack = attack,
decay = decay,
sustain = sustain,
release = release
)
}
}

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

View File

@@ -34,14 +34,16 @@ import nl.astraeus.komp.HtmlBuilder
import nl.astraeus.komp.Komponent
import nl.astraeus.komp.currentElement
import nl.astraeus.vst.chip.audio.VstChipWorklet
import nl.astraeus.vst.chip.audio.VstChipWorklet.midiChannel
import nl.astraeus.vst.chip.midi.Midi
import nl.astraeus.vst.chip.ws.WebsocketClient
import nl.astraeus.vst.ui.components.KnobComponent
import nl.astraeus.vst.ui.css.Css
import nl.astraeus.vst.ui.css.Css.defineCss
import nl.astraeus.vst.ui.css.Css.noTextSelect
import nl.astraeus.vst.ui.css.CssName
import nl.astraeus.vst.ui.css.hover
import org.khronos.webgl.Uint8Array
import nl.astraeus.vst.ui.util.uInt8ArrayOf
import org.khronos.webgl.get
import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement
@@ -75,10 +77,11 @@ object WaveformView: Komponent() {
val height = ctx.canvas.height.toDouble()
val halfHeight = height / 2.0
ctx.lineWidth = 2.0
ctx.clearRect(0.0, 0.0, width, height)
val step = 1000.0 / data.length
ctx.beginPath()
ctx.strokeStyle = "rgba(255, 255, 255, 0.5)"
ctx.strokeStyle = "rgba(0, 255, 255, 0.5)"
ctx.moveTo(0.0, halfHeight)
for (i in 0 until data.length) {
ctx.lineTo(i * step, halfHeight - data[i] * halfHeight)
@@ -119,6 +122,7 @@ object MainView : Komponent(), CssName {
VstChipWorklet.create {
started = true
requestUpdate()
WebsocketClient.send("LOAD\n")
}
}
}
@@ -140,6 +144,7 @@ object MainView : Komponent(), CssName {
option {
+mi.name
value = mi.id
selected = mi.id == Midi.currentInput?.id
}
}
@@ -148,12 +153,7 @@ object MainView : Komponent(), CssName {
if (target.value == "none") {
Midi.setInput(null)
} else {
val selected = Midi.inputs.find { it.id == target.value }
if (selected != null) {
Midi.setInput(selected)
} else if (target.value == "midi-broadcast") {
//
}
Midi.setInput(target.value)
}
}
}
@@ -172,71 +172,20 @@ object MainView : Komponent(), CssName {
}
}
div {
span {
+"Midi output: "
select {
option {
+"None"
value = "none"
}
for (mi in Midi.outputs) {
option {
+mi.name
value = mi.id
}
}
span(ButtonBarCss.name) {
+"SAVE"
onClickFunction = {
val patch = VstChipWorklet.save().copy(midiId = Midi.currentInput?.id ?: "")
onChangeFunction = { event ->
val target = event.target as HTMLSelectElement
if (target.value == "none") {
Midi.setOutput(null)
} else {
val selected = Midi.outputs.find { it.id == target.value }
if (selected != null) {
Midi.setOutput(selected)
}
}
}
WebsocketClient.send("SAVE\n${JSON.stringify(patch)}")
}
}
span {
+"channel:"
input {
type = InputType.number
value = Midi.outputChannel.toString()
onInputFunction = { event ->
val target = event.target as HTMLInputElement
Midi.outputChannel = target.value.toInt()
}
}
}
}
div {
span(ButtonCss.name) {
+"Send note on to output"
span(ButtonBarCss.name) {
+"STOP"
onClickFunction = {
val data = Uint8Array(
arrayOf(
0x90.toByte(),
0x3c.toByte(),
0x70.toByte()
)
VstChipWorklet.postDirectlyToWorklet(
uInt8ArrayOf(0xb0 + midiChannel, 123, 0)
)
Midi.send(data, window.performance.now() + 1000)
Midi.send(data, window.performance.now() + 2000)
}
}
span(ButtonCss.name) {
+"Send note off to output"
onClickFunction = {
val data = Uint8Array(
arrayOf(
0x90.toByte(),
0x3c.toByte(),
0x0.toByte(),
)
)
Midi.send(data)
}
}
}
@@ -362,6 +311,60 @@ object MainView : Komponent(), CssName {
}
)
}
div(ControlsCss.name) {
include(
KnobComponent(
value = VstChipWorklet.attack,
label = "Attack",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.attack = value
}
)
include(
KnobComponent(
value = VstChipWorklet.decay,
label = "Decay",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.decay = value
}
)
include(
KnobComponent(
value = VstChipWorklet.sustain,
label = "Sustain",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.sustain = value
}
)
include(
KnobComponent(
value = VstChipWorklet.release,
label = "Release",
minValue = 0.0,
maxValue = 1.0,
step = 2.0 / 127.0,
width = 100,
height = 120,
) { value ->
VstChipWorklet.release = value
}
)
}
include(WaveformView)
}
}

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
import com.zaxxer.hikari.HikariConfig
import io.undertow.Undertow
import io.undertow.UndertowOptions
import io.undertow.server.session.InMemorySessionManager
import io.undertow.server.session.SessionAttachmentHandler
import io.undertow.server.session.SessionCookieConfig
import nl.astraeus.vst.chip.db.Database
import nl.astraeus.vst.chip.logger.LogLevel
import nl.astraeus.vst.chip.logger.Logger
import nl.astraeus.vst.chip.web.RequestHandler
fun main() {
Logger.level = LogLevel.DEBUG
Thread.setDefaultUncaughtExceptionHandler { _, e ->
e.printStackTrace()
}
Class.forName("nl.astraeus.jdbc.Driver")
Database.initialize(HikariConfig().apply {
driverClassName = "nl.astraeus.jdbc.Driver"
jdbcUrl = "jdbc:stat:webServerPort=6002:jdbc:sqlite:data/chip.db"
username = "sa"
password = ""
maximumPoolSize = 25
isAutoCommit = false
validate()
})
val sessionHandler = SessionAttachmentHandler(
InMemorySessionManager("vst-session-manager"),
SessionCookieConfig()
)
sessionHandler.setNext(RequestHandler)
val server = Undertow.builder()
.addHttpListener(Settings.port, "localhost")
.setIoThreads(4)
.setHandler(RequestHandler)
.setHandler(sessionHandler)
.setServerOption(UndertowOptions.SHUTDOWN_TIMEOUT, 1000)
.build()

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
)